Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 85 additions & 8 deletions packages/api/src/services/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const upsertProjectIndex = (projectId: string, agentId: string): void => {
}

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

const agentHome = (sshUser: string): string => `/home/${sshUser}`

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

const buildAgentScript = (
const buildEnvExports = (
envEntries: ReadonlyArray<{ readonly key: string; readonly value: string }>
): string => envEntries
.map(({ key, value }) => {
if (!agentEnvKeyPattern.test(key)) {
throw new ApiBadRequestError({ message: `Invalid agent env key: ${key}` })
}
return `export ${key}=${shellEscape(value)}`
})
.join("\n")

const execLine = (command: string): string => {
const parts = command.trim().split(/\s+/u)
const firstCommandIndex = parts.findIndex((part) => !simpleEnvAssignmentPattern.test(part))

return firstCommandIndex > 0
? `exec env ${parts.slice(0, firstCommandIndex).join(" ")} ${parts.slice(firstCommandIndex).join(" ")}`
: `exec ${command}`
}

export const buildAgentScript = (
sessionId: string,
cwd: string,
sshUser: string,
codexHome: string,
envEntries: ReadonlyArray<{ readonly key: string; readonly value: string }>,
command: string
): string => {
const pidFile = `/tmp/docker-git-agent-${sessionId}.pid`
const exports = envEntries
.map(({ key, value }) => `export ${key}=${shellEscape(value)}`)
.join("\n")
const home = agentHome(sshUser)
const sshEnvPath = `${home}/.ssh/environment`
const exports = buildEnvExports(envEntries)

return [
"set -euo pipefail",
"set -eo pipefail",
`PID_FILE=${shellEscape(pidFile)}`,
"cleanup() { rm -f \"$PID_FILE\"; }",
"trap cleanup EXIT",
"echo $$ > \"$PID_FILE\"",
`export HOME=${shellEscape(home)}`,
`export USER=${shellEscape(sshUser)}`,
`export LOGNAME=${shellEscape(sshUser)}`,
`export CODEX_HOME=${shellEscape(codexHome)}`,
"export DOCKER_GIT_RTK_ENABLE=\"${DOCKER_GIT_RTK_ENABLE:-1}\"",
"if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi",
`if [ -f ${shellEscape(sshEnvPath)} ]; then`,
" set -a",
` . ${shellEscape(sshEnvPath)} >/dev/null 2>&1 || true`,
" set +a",
"fi",
"if [ -f /run/docker-git/agent-env.sh ]; then . /run/docker-git/agent-env.sh >/dev/null 2>&1 || true; fi",
`export HOME=${shellEscape(home)}`,
`export USER=${shellEscape(sshUser)}`,
`export LOGNAME=${shellEscape(sshUser)}`,
`export CODEX_HOME=${shellEscape(codexHome)}`,
"export DOCKER_GIT_RTK_ENABLE=\"${DOCKER_GIT_RTK_ENABLE:-1}\"",
"set -u",
`cd ${shellEscape(cwd)}`,
exports,
`exec ${command}`
execLine(command)
]
.filter((line) => line.trim().length > 0)
.join("\n")
}

export const buildAgentDockerExecArgs = (
project: Pick<ProjectDetails, "containerName" | "sshUser" | "codexHome">,
script: string
): ReadonlyArray<string> => {
const home = agentHome(project.sshUser)

return [
"exec",
"-i",
"-u",
project.sshUser,
"-e",
`HOME=${home}`,
"-e",
`USER=${project.sshUser}`,
"-e",
`LOGNAME=${project.sshUser}`,
"-e",
`CODEX_HOME=${project.codexHome}`,
project.containerName,
"bash",
"-lc",
script
]
}

const trimLogs = (logs: Array<AgentLogLine>): Array<AgentLogLine> =>
logs.length <= maxLogLines ? logs : logs.slice(logs.length - maxLogLines)

Expand Down Expand Up @@ -316,6 +386,14 @@ export const startAgent = (
updatedAt: startedAt
}

const script = buildAgentScript(
sessionId,
workingDir,
project.sshUser,
project.codexHome,
request.env ?? [],
command
)
const record: AgentRecord = {
session,
projectDir: project.projectDir,
Expand All @@ -328,10 +406,9 @@ export const startAgent = (
records.set(sessionId, record)
upsertProjectIndex(project.id, sessionId)

const script = buildAgentScript(sessionId, workingDir, request.env ?? [], command)
const child = spawn(
"docker",
["exec", "-i", project.containerName, "bash", "-lc", script],
[...buildAgentDockerExecArgs(project, script)],
{
cwd: project.projectDir,
env: process.env,
Expand Down
77 changes: 76 additions & 1 deletion packages/api/tests/agents.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"

import { buildCommand } from "../src/services/agents.js"
import { buildAgentDockerExecArgs, buildAgentScript, buildCommand } from "../src/services/agents.js"

describe("agent service", () => {
it("starts default Codex agents with isolated Playwright MCP", () => {
Expand All @@ -17,7 +17,82 @@ describe("agent service", () => {
)
})

it("starts default OpenCode agents without extra env assignments", () => {
expect(buildCommand({ provider: "opencode" })).toBe("opencode")
})

it("does not rewrite custom agent commands", () => {
expect(buildCommand({ provider: "codex", command: "codex --help" })).toBe("codex --help")
})

it("runs agent scripts in the project SSH user's RTK-ready environment", () => {
const script = buildAgentScript(
"session-1",
"/home/dev/app",
"dev",
"/home/dev/.codex",
[
{ key: "DOCKER_GIT_RTK_ENABLE", value: "0" },
{ key: "QUOTED", value: "can't fail" }
],
"MCP_PLAYWRIGHT_ISOLATED=1 codex 'exec' 'hello world'"
)

expect(script).toContain("echo $$ > \"$PID_FILE\"")
expect(script).toContain("export HOME='/home/dev'")
expect(script).toContain("export USER='dev'")
expect(script).toContain("export LOGNAME='dev'")
expect(script).toContain("export CODEX_HOME='/home/dev/.codex'")
expect(script).toContain("if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi")
expect(script).toContain("if [ -f '/home/dev/.ssh/environment' ]; then")
expect(script).toContain(
"if [ -f /run/docker-git/agent-env.sh ]; then . /run/docker-git/agent-env.sh >/dev/null 2>&1 || true; fi"
)
expect(script).toContain("export DOCKER_GIT_RTK_ENABLE='0'")
expect(script).toContain("export QUOTED='can'\\''t fail'")
expect(script).toContain("cd '/home/dev/app'")
expect(script).toContain("exec env MCP_PLAYWRIGHT_ISOLATED=1 codex 'exec' 'hello world'")
expect(script.indexOf("if [ -f /run/docker-git/agent-env.sh ]")).toBeLessThan(
script.indexOf("export DOCKER_GIT_RTK_ENABLE='0'")
)
})

it("rejects invalid agent env keys before rendering shell exports", () => {
expect(() =>
buildAgentScript(
"session-1",
"/home/dev/app",
"dev",
"/home/dev/.codex",
[{ key: "BAD;echo hacked", value: "1" }],
"opencode"
)
).toThrow("Invalid agent env key: BAD;echo hacked")
})

it("uses docker exec as the project SSH user with the user home env", () => {
const args = buildAgentDockerExecArgs(
{ containerName: "dev-ssh", sshUser: "dev", codexHome: "/home/dev/.codex" },
"echo ok"
)

expect(args).toEqual([
"exec",
"-i",
"-u",
"dev",
"-e",
"HOME=/home/dev",
"-e",
"USER=dev",
"-e",
"LOGNAME=dev",
"-e",
"CODEX_HOME=/home/dev/.codex",
"dev-ssh",
"bash",
"-lc",
"echo ok"
])
})
})
1 change: 1 addition & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Options:
Container runtime env (set via .orch/env/project.env):
CODEX_SHARE_AUTH=1|0 Share Codex auth.json across projects (default: 1)
CODEX_AUTO_UPDATE=1|0 Auto-update Codex CLI on container start (default: 1)
DOCKER_GIT_RTK_ENABLE=1|0 Configure RTK token-saving hooks/instructions on container start (default: 1)
CLAUDE_AUTO_SYSTEM_PROMPT=1|0 Auto-attach docker-git managed system prompt to claude (default: 1)
CLAUDE_SYSTEM_PROMPT_OVERRIDE=<text> Custom Claude system prompt body (overrides default Russian template)
CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE=<path> Path to file with custom Claude prompt (takes precedence over OVERRIDE)
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/lib/core/templates-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates
import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js"
import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js"
import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js"
import { renderEntrypointRtkConfig } from "./templates-entrypoint/rtk.js"
import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js"
import {
renderEntrypointBashCompletion,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
renderEntrypointGitConfig(config),
renderEntrypointClaudeConfig(config),
renderEntrypointGeminiConfig(config),
renderEntrypointRtkConfig(config),
renderEntrypointGitHooks(),
renderEntrypointBackgroundTasks(config),
renderEntrypointBaseline(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then
# docker-git project env defaults
CODEX_SHARE_AUTH=1
CODEX_AUTO_UPDATE=1
DOCKER_GIT_RTK_ENABLE=1
DOCKER_GIT_ZSH_AUTOSUGGEST=0
DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic
DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion
Expand Down
47 changes: 47 additions & 0 deletions packages/app/src/lib/core/templates-entrypoint/rtk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* jscpd:ignore-start */
import type { TemplateConfig } from "../domain.js"

// CHANGE: configure RTK hooks/instructions for the bundled AI agents at startup.
// WHY: generated docker-git containers should reduce command-output tokens without manual setup.
// QUOTE(TASK): "make it work out of the box for docker-git"
// REF: issue-266
// SOURCE: https://github.com/rtk-ai/rtk/blob/develop/README.md
// FORMAT THEOREM: forall start: RTK_ENABLED(start) -> configured(codex, claude, gemini, opencode)
// PURITY: CORE (pure template renderer)
// INVARIANT: RTK init runs as the non-root SSH user and never blocks container startup.
// COMPLEXITY: O(1)
export const renderEntrypointRtkConfig = (config: TemplateConfig): string =>
String.raw`# RTK: configure command-output token optimization for supported agents.
DOCKER_GIT_RTK_ENABLE="${"$"}{DOCKER_GIT_RTK_ENABLE:-1}"
docker_git_upsert_ssh_env "DOCKER_GIT_RTK_ENABLE" "$DOCKER_GIT_RTK_ENABLE"

docker_git_rtk_init_as_user() {
local label="$1"
local command="$2"

if [[ "$DOCKER_GIT_RTK_ENABLE" != "1" ]]; then
return 0
fi

if ! command -v rtk >/dev/null 2>&1; then
echo "[rtk] warning: rtk binary not found; skipping $label setup" >&2
return 0
fi

mkdir -p "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config/opencode" "/home/__SSH_USER__/.gemini" || true
chown -R 1000:1000 "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config" "/home/__SSH_USER__/.gemini" 2>/dev/null || true

if su - __SSH_USER__ -s /bin/bash -c "$command" </dev/null; then
echo "[rtk] configured $label"
else
echo "[rtk] warning: failed to configure $label" >&2
fi
}

docker_git_rtk_init_as_user "codex" "HOME=/home/__SSH_USER__ CODEX_HOME='__CODEX_HOME__' rtk init -g --codex"
docker_git_rtk_init_as_user "claude" "HOME=/home/__SSH_USER__ RTK_CLAUDE_DIR='$CLAUDE_CONFIG_DIR' rtk init -g --auto-patch"
docker_git_rtk_init_as_user "gemini" "HOME=/home/__SSH_USER__ rtk init -g --gemini --auto-patch"
docker_git_rtk_init_as_user "opencode" "HOME=/home/__SSH_USER__ rtk init -g --opencode"`
.replaceAll("__SSH_USER__", config.sshUser)
.replaceAll("__CODEX_HOME__", config.codexHome)
/* jscpd:ignore-end */
21 changes: 20 additions & 1 deletion packages/app/src/lib/core/templates/dockerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ RUN set -eu; \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
openssh-server git gh ca-certificates curl unzip bsdutils sudo \
make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \
ncurses-term \
ncurses-term jq \
&& rm -rf /var/lib/apt/lists/*

# Passwordless sudo for all users (container is disposable)
Expand Down Expand Up @@ -85,6 +85,24 @@ RUN claude --version
RUN npm install -g @google/gemini-cli@latest --force
RUN gemini --version`

// CHANGE: install RTK as a real command-output optimizer in generated containers.
// WHY: issue-266 asks for out-of-the-box RTK behavior, not only a session-sync estimate.
// REF: issue-266
// SOURCE: https://github.com/rtk-ai/rtk/blob/develop/install.sh
// PURITY: CORE (pure template renderer)
// INVARIANT: rtk is available on PATH under /usr/local/bin during container runtime
// COMPLEXITY: O(1)
const renderDockerfileRtk = (): string =>
`# Tooling: RTK (Rust Token Killer)
RUN set -eu; \
curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \
https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh \
-o /tmp/rtk-install.sh; \
RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \
rm -f /tmp/rtk-install.sh; \
rtk --version; \
rtk gain >/dev/null 2>&1 || true`

const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest"

const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest
Expand Down Expand Up @@ -267,6 +285,7 @@ export const renderDockerfile = (config: TemplateConfig): string =>
renderDockerfilePrompt(),
renderDockerfileNode(),
renderDockerfileBun(config),
renderDockerfileRtk(),
renderDockerfileOpenCode(),
renderDockerfileGitleaks(),
renderDockerfileUsers(config),
Expand Down
Loading