Skip to content

Commit ca1b4c4

Browse files
authored
feat(agent): enable RTK for docker-git agents
Merge PR #283 after updating with origin/main and passing all checks.
2 parents 65c4f84 + 96f6ee1 commit ca1b4c4

16 files changed

Lines changed: 395 additions & 20 deletions

File tree

packages/api/src/services/agents.ts

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const upsertProjectIndex = (projectId: string, agentId: string): void => {
4949
}
5050

5151
const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
52+
const agentEnvKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/u
53+
const simpleEnvAssignmentPattern = /^[A-Za-z_][A-Za-z0-9_]*=[^\s]+$/u
54+
55+
const agentHome = (sshUser: string): string => `/home/${sshUser}`
5256

5357
const sourceLabel = (request: CreateAgentRequest): string =>
5458
request.label?.trim().length ? request.label.trim() : request.provider
@@ -81,31 +85,97 @@ export const buildCommand = (request: CreateAgentRequest): string => {
8185
return args.length === 0 ? base : `${base} ${args.join(" ")}`
8286
}
8387

84-
const buildAgentScript = (
88+
const buildEnvExports = (
89+
envEntries: ReadonlyArray<{ readonly key: string; readonly value: string }>
90+
): string => envEntries
91+
.map(({ key, value }) => {
92+
if (!agentEnvKeyPattern.test(key)) {
93+
throw new ApiBadRequestError({ message: `Invalid agent env key: ${key}` })
94+
}
95+
return `export ${key}=${shellEscape(value)}`
96+
})
97+
.join("\n")
98+
99+
const execLine = (command: string): string => {
100+
const parts = command.trim().split(/\s+/u)
101+
const firstCommandIndex = parts.findIndex((part) => !simpleEnvAssignmentPattern.test(part))
102+
103+
return firstCommandIndex > 0
104+
? `exec env ${parts.slice(0, firstCommandIndex).join(" ")} ${parts.slice(firstCommandIndex).join(" ")}`
105+
: `exec ${command}`
106+
}
107+
108+
export const buildAgentScript = (
85109
sessionId: string,
86110
cwd: string,
111+
sshUser: string,
112+
codexHome: string,
87113
envEntries: ReadonlyArray<{ readonly key: string; readonly value: string }>,
88114
command: string
89115
): string => {
90116
const pidFile = `/tmp/docker-git-agent-${sessionId}.pid`
91-
const exports = envEntries
92-
.map(({ key, value }) => `export ${key}=${shellEscape(value)}`)
93-
.join("\n")
117+
const home = agentHome(sshUser)
118+
const sshEnvPath = `${home}/.ssh/environment`
119+
const exports = buildEnvExports(envEntries)
94120

95121
return [
96-
"set -euo pipefail",
122+
"set -eo pipefail",
97123
`PID_FILE=${shellEscape(pidFile)}`,
98124
"cleanup() { rm -f \"$PID_FILE\"; }",
99125
"trap cleanup EXIT",
100126
"echo $$ > \"$PID_FILE\"",
127+
`export HOME=${shellEscape(home)}`,
128+
`export USER=${shellEscape(sshUser)}`,
129+
`export LOGNAME=${shellEscape(sshUser)}`,
130+
`export CODEX_HOME=${shellEscape(codexHome)}`,
131+
"export DOCKER_GIT_RTK_ENABLE=\"${DOCKER_GIT_RTK_ENABLE:-1}\"",
132+
"if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi",
133+
`if [ -f ${shellEscape(sshEnvPath)} ]; then`,
134+
" set -a",
135+
` . ${shellEscape(sshEnvPath)} >/dev/null 2>&1 || true`,
136+
" set +a",
137+
"fi",
138+
"if [ -f /run/docker-git/agent-env.sh ]; then . /run/docker-git/agent-env.sh >/dev/null 2>&1 || true; fi",
139+
`export HOME=${shellEscape(home)}`,
140+
`export USER=${shellEscape(sshUser)}`,
141+
`export LOGNAME=${shellEscape(sshUser)}`,
142+
`export CODEX_HOME=${shellEscape(codexHome)}`,
143+
"export DOCKER_GIT_RTK_ENABLE=\"${DOCKER_GIT_RTK_ENABLE:-1}\"",
144+
"set -u",
101145
`cd ${shellEscape(cwd)}`,
102146
exports,
103-
`exec ${command}`
147+
execLine(command)
104148
]
105149
.filter((line) => line.trim().length > 0)
106150
.join("\n")
107151
}
108152

153+
export const buildAgentDockerExecArgs = (
154+
project: Pick<ProjectDetails, "containerName" | "sshUser" | "codexHome">,
155+
script: string
156+
): ReadonlyArray<string> => {
157+
const home = agentHome(project.sshUser)
158+
159+
return [
160+
"exec",
161+
"-i",
162+
"-u",
163+
project.sshUser,
164+
"-e",
165+
`HOME=${home}`,
166+
"-e",
167+
`USER=${project.sshUser}`,
168+
"-e",
169+
`LOGNAME=${project.sshUser}`,
170+
"-e",
171+
`CODEX_HOME=${project.codexHome}`,
172+
project.containerName,
173+
"bash",
174+
"-lc",
175+
script
176+
]
177+
}
178+
109179
const trimLogs = (logs: Array<AgentLogLine>): Array<AgentLogLine> =>
110180
logs.length <= maxLogLines ? logs : logs.slice(logs.length - maxLogLines)
111181

@@ -316,6 +386,14 @@ export const startAgent = (
316386
updatedAt: startedAt
317387
}
318388

389+
const script = buildAgentScript(
390+
sessionId,
391+
workingDir,
392+
project.sshUser,
393+
project.codexHome,
394+
request.env ?? [],
395+
command
396+
)
319397
const record: AgentRecord = {
320398
session,
321399
projectDir: project.projectDir,
@@ -328,10 +406,9 @@ export const startAgent = (
328406
records.set(sessionId, record)
329407
upsertProjectIndex(project.id, sessionId)
330408

331-
const script = buildAgentScript(sessionId, workingDir, request.env ?? [], command)
332409
const child = spawn(
333410
"docker",
334-
["exec", "-i", project.containerName, "bash", "-lc", script],
411+
[...buildAgentDockerExecArgs(project, script)],
335412
{
336413
cwd: project.projectDir,
337414
env: process.env,

packages/api/tests/agents.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest"
22

3-
import { buildCommand } from "../src/services/agents.js"
3+
import { buildAgentDockerExecArgs, buildAgentScript, buildCommand } from "../src/services/agents.js"
44

55
describe("agent service", () => {
66
it("starts default Codex agents with isolated Playwright MCP", () => {
@@ -17,7 +17,82 @@ describe("agent service", () => {
1717
)
1818
})
1919

20+
it("starts default OpenCode agents without extra env assignments", () => {
21+
expect(buildCommand({ provider: "opencode" })).toBe("opencode")
22+
})
23+
2024
it("does not rewrite custom agent commands", () => {
2125
expect(buildCommand({ provider: "codex", command: "codex --help" })).toBe("codex --help")
2226
})
27+
28+
it("runs agent scripts in the project SSH user's RTK-ready environment", () => {
29+
const script = buildAgentScript(
30+
"session-1",
31+
"/home/dev/app",
32+
"dev",
33+
"/home/dev/.codex",
34+
[
35+
{ key: "DOCKER_GIT_RTK_ENABLE", value: "0" },
36+
{ key: "QUOTED", value: "can't fail" }
37+
],
38+
"MCP_PLAYWRIGHT_ISOLATED=1 codex 'exec' 'hello world'"
39+
)
40+
41+
expect(script).toContain("echo $$ > \"$PID_FILE\"")
42+
expect(script).toContain("export HOME='/home/dev'")
43+
expect(script).toContain("export USER='dev'")
44+
expect(script).toContain("export LOGNAME='dev'")
45+
expect(script).toContain("export CODEX_HOME='/home/dev/.codex'")
46+
expect(script).toContain("if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi")
47+
expect(script).toContain("if [ -f '/home/dev/.ssh/environment' ]; then")
48+
expect(script).toContain(
49+
"if [ -f /run/docker-git/agent-env.sh ]; then . /run/docker-git/agent-env.sh >/dev/null 2>&1 || true; fi"
50+
)
51+
expect(script).toContain("export DOCKER_GIT_RTK_ENABLE='0'")
52+
expect(script).toContain("export QUOTED='can'\\''t fail'")
53+
expect(script).toContain("cd '/home/dev/app'")
54+
expect(script).toContain("exec env MCP_PLAYWRIGHT_ISOLATED=1 codex 'exec' 'hello world'")
55+
expect(script.indexOf("if [ -f /run/docker-git/agent-env.sh ]")).toBeLessThan(
56+
script.indexOf("export DOCKER_GIT_RTK_ENABLE='0'")
57+
)
58+
})
59+
60+
it("rejects invalid agent env keys before rendering shell exports", () => {
61+
expect(() =>
62+
buildAgentScript(
63+
"session-1",
64+
"/home/dev/app",
65+
"dev",
66+
"/home/dev/.codex",
67+
[{ key: "BAD;echo hacked", value: "1" }],
68+
"opencode"
69+
)
70+
).toThrow("Invalid agent env key: BAD;echo hacked")
71+
})
72+
73+
it("uses docker exec as the project SSH user with the user home env", () => {
74+
const args = buildAgentDockerExecArgs(
75+
{ containerName: "dev-ssh", sshUser: "dev", codexHome: "/home/dev/.codex" },
76+
"echo ok"
77+
)
78+
79+
expect(args).toEqual([
80+
"exec",
81+
"-i",
82+
"-u",
83+
"dev",
84+
"-e",
85+
"HOME=/home/dev",
86+
"-e",
87+
"USER=dev",
88+
"-e",
89+
"LOGNAME=dev",
90+
"-e",
91+
"CODEX_HOME=/home/dev/.codex",
92+
"dev-ssh",
93+
"bash",
94+
"-lc",
95+
"echo ok"
96+
])
97+
})
2398
})

packages/app/src/docker-git/cli/usage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Options:
8181
Container runtime env (set via .orch/env/project.env):
8282
CODEX_SHARE_AUTH=1|0 Share Codex auth.json across projects (default: 1)
8383
CODEX_AUTO_UPDATE=1|0 Auto-update Codex CLI on container start (default: 1)
84+
DOCKER_GIT_RTK_ENABLE=1|0 Configure RTK token-saving hooks/instructions on container start (default: 1)
8485
CLAUDE_AUTO_SYSTEM_PROMPT=1|0 Auto-attach docker-git managed system prompt to claude (default: 1)
8586
CLAUDE_SYSTEM_PROMPT_OVERRIDE=<text> Custom Claude system prompt body (overrides default Russian template)
8687
CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE=<path> Path to file with custom Claude prompt (takes precedence over OVERRIDE)

packages/app/src/lib/core/templates-entrypoint.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates
2727
import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js"
2828
import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js"
2929
import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js"
30+
import { renderEntrypointRtkConfig } from "./templates-entrypoint/rtk.js"
3031
import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js"
3132
import {
3233
renderEntrypointBashCompletion,
@@ -61,6 +62,7 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
6162
renderEntrypointGitConfig(config),
6263
renderEntrypointClaudeConfig(config),
6364
renderEntrypointGeminiConfig(config),
65+
renderEntrypointRtkConfig(config),
6466
renderEntrypointGitHooks(),
6567
renderEntrypointBackgroundTasks(config),
6668
renderEntrypointBaseline(),

packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then
113113
# docker-git project env defaults
114114
CODEX_SHARE_AUTH=1
115115
CODEX_AUTO_UPDATE=1
116+
DOCKER_GIT_RTK_ENABLE=1
116117
DOCKER_GIT_ZSH_AUTOSUGGEST=0
117118
DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic
118119
DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/* jscpd:ignore-start */
2+
import type { TemplateConfig } from "../domain.js"
3+
4+
// CHANGE: configure RTK hooks/instructions for the bundled AI agents at startup.
5+
// WHY: generated docker-git containers should reduce command-output tokens without manual setup.
6+
// QUOTE(TASK): "make it work out of the box for docker-git"
7+
// REF: issue-266
8+
// SOURCE: https://github.com/rtk-ai/rtk/blob/develop/README.md
9+
// FORMAT THEOREM: forall start: RTK_ENABLED(start) -> configured(codex, claude, gemini, opencode)
10+
// PURITY: CORE (pure template renderer)
11+
// INVARIANT: RTK init runs as the non-root SSH user and never blocks container startup.
12+
// COMPLEXITY: O(1)
13+
export const renderEntrypointRtkConfig = (config: TemplateConfig): string =>
14+
String.raw`# RTK: configure command-output token optimization for supported agents.
15+
DOCKER_GIT_RTK_ENABLE="${"$"}{DOCKER_GIT_RTK_ENABLE:-1}"
16+
docker_git_upsert_ssh_env "DOCKER_GIT_RTK_ENABLE" "$DOCKER_GIT_RTK_ENABLE"
17+
18+
docker_git_rtk_init_as_user() {
19+
local label="$1"
20+
local command="$2"
21+
22+
if [[ "$DOCKER_GIT_RTK_ENABLE" != "1" ]]; then
23+
return 0
24+
fi
25+
26+
if ! command -v rtk >/dev/null 2>&1; then
27+
echo "[rtk] warning: rtk binary not found; skipping $label setup" >&2
28+
return 0
29+
fi
30+
31+
mkdir -p "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config/opencode" "/home/__SSH_USER__/.gemini" || true
32+
chown -R 1000:1000 "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config" "/home/__SSH_USER__/.gemini" 2>/dev/null || true
33+
34+
if su - __SSH_USER__ -s /bin/bash -c "$command" </dev/null; then
35+
echo "[rtk] configured $label"
36+
else
37+
echo "[rtk] warning: failed to configure $label" >&2
38+
fi
39+
}
40+
41+
docker_git_rtk_init_as_user "codex" "HOME=/home/__SSH_USER__ CODEX_HOME='__CODEX_HOME__' rtk init -g --codex"
42+
docker_git_rtk_init_as_user "claude" "HOME=/home/__SSH_USER__ RTK_CLAUDE_DIR='$CLAUDE_CONFIG_DIR' rtk init -g --auto-patch"
43+
docker_git_rtk_init_as_user "gemini" "HOME=/home/__SSH_USER__ rtk init -g --gemini --auto-patch"
44+
docker_git_rtk_init_as_user "opencode" "HOME=/home/__SSH_USER__ rtk init -g --opencode"`
45+
.replaceAll("__SSH_USER__", config.sshUser)
46+
.replaceAll("__CODEX_HOME__", config.codexHome)
47+
/* jscpd:ignore-end */

packages/app/src/lib/core/templates/dockerfile.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ RUN set -eu; \
3232
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
3333
openssh-server git gh ca-certificates curl unzip bsdutils sudo \
3434
make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \
35-
ncurses-term \
35+
ncurses-term jq \
3636
&& rm -rf /var/lib/apt/lists/*
3737
3838
# Passwordless sudo for all users (container is disposable)
@@ -85,6 +85,24 @@ RUN claude --version
8585
RUN npm install -g @google/gemini-cli@latest --force
8686
RUN gemini --version`
8787

88+
// CHANGE: install RTK as a real command-output optimizer in generated containers.
89+
// WHY: issue-266 asks for out-of-the-box RTK behavior, not only a session-sync estimate.
90+
// REF: issue-266
91+
// SOURCE: https://github.com/rtk-ai/rtk/blob/develop/install.sh
92+
// PURITY: CORE (pure template renderer)
93+
// INVARIANT: rtk is available on PATH under /usr/local/bin during container runtime
94+
// COMPLEXITY: O(1)
95+
const renderDockerfileRtk = (): string =>
96+
`# Tooling: RTK (Rust Token Killer)
97+
RUN set -eu; \
98+
curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \
99+
https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh \
100+
-o /tmp/rtk-install.sh; \
101+
RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \
102+
rm -f /tmp/rtk-install.sh; \
103+
rtk --version; \
104+
rtk gain >/dev/null 2>&1 || true`
105+
88106
const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest"
89107

90108
const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest
@@ -267,6 +285,7 @@ export const renderDockerfile = (config: TemplateConfig): string =>
267285
renderDockerfilePrompt(),
268286
renderDockerfileNode(),
269287
renderDockerfileBun(config),
288+
renderDockerfileRtk(),
270289
renderDockerfileOpenCode(),
271290
renderDockerfileGitleaks(),
272291
renderDockerfileUsers(config),

0 commit comments

Comments
 (0)