Skip to content

Commit da32507

Browse files
centdixclaude
andauthored
fix: stabilize docker sandbox shell startup (#209)
* fix: stabilize docker sandbox shell startup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: preserve container path in sandbox startup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e5185ab commit da32507

6 files changed

Lines changed: 67 additions & 17 deletions

File tree

.webmux.example.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ profiles:
108108
yolo: true
109109

110110
# Required when `runtime: docker`.
111+
# Custom images should expose `claude` or `codex` on the container PATH
112+
# via the image itself, e.g. `ENV PATH=/your/tool/bin:$PATH`.
111113
image: example-sandbox-image
112114

113115
envPassthrough:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ profiles:
130130
You are running inside a sandboxed container.
131131
Backend port: ${PORT}. Frontend port: ${FRONTEND_PORT}.
132132
133+
Custom sandbox images should make `claude` or `codex` available on the container's normal `PATH` (for example with `ENV PATH=/your/tool/bin:$PATH`). webmux does not rely on login-shell dotfiles like `.bashrc` to discover agent binaries inside the container.
134+
133135
integrations:
134136
github:
135137
autoRemoveOnMerge: true

backend/src/__tests__/agent-service.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,45 @@ describe("agent-service command builders", () => {
5959
);
6060
const agent = buildDockerAgentPaneCommand({
6161
agent: "codex",
62-
containerName: "wm-feature-container",
63-
worktreePath: "/repos/feature",
6462
runtimeEnvPath: "/repos/main/.git/worktrees/feature/webmux/runtime.env",
6563
yolo: true,
6664
prompt: "ship the fix",
6765
});
6866

69-
expect(shell).toContain("docker exec -it -w '/repos/feature' 'wm-feature-container' bash -lc");
67+
expect(shell).toContain("docker exec -it -w '/repos/feature' 'wm-feature-container' /bin/sh -c");
7068
expect(shell).toContain("/bin/zsh");
69+
expect(shell).toContain('export PATH="$PATH:/root/.local/bin:/usr/local/bin:/root/.bun/bin:/root/.cargo/bin"');
7170
expect(agent).toContain("codex --yolo");
7271
expect(agent).toContain("ship the fix");
72+
expect(agent).toContain('export PATH="$PATH:/root/.local/bin:/usr/local/bin:/root/.bun/bin:/root/.cargo/bin"');
73+
expect(agent).not.toContain("docker exec");
7374
expect(agent).not.toContain("agent-stopped");
7475
});
7576

77+
it("defaults docker shell commands to /bin/bash instead of the host shell path", () => {
78+
const shell = buildDockerShellCommand(
79+
"wm-feature-container",
80+
"/repos/feature",
81+
"/repos/main/.git/worktrees/feature/webmux/runtime.env",
82+
);
83+
84+
expect(shell).toContain("/bin/bash");
85+
expect(shell).not.toContain(" /bin/sh -lc ");
86+
expect(shell).toContain('export PATH="$PATH:/root/.local/bin:/usr/local/bin:/root/.bun/bin:/root/.cargo/bin"');
87+
});
88+
89+
it("falls back to /bin/sh when the preferred docker shell is unavailable", () => {
90+
const shell = buildDockerShellCommand(
91+
"wm-feature-container",
92+
"/repos/feature",
93+
"/repos/main/.git/worktrees/feature/webmux/runtime.env",
94+
"/missing/bash",
95+
);
96+
97+
expect(shell).toContain("/missing/bash");
98+
expect(shell).toContain("elif [ -x /bin/sh ]; then exec /bin/sh -i;");
99+
});
100+
76101
it("uses codex resume --last on resume without replaying the initial prompt", () => {
77102
const command = buildAgentPaneCommand({
78103
agent: "codex",

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function run(args: string[], cwd: string): string {
3737

3838
class FakeTmuxGateway implements TmuxGateway {
3939
private readonly windows = new Map<string, TmuxWindowSummary>();
40+
readonly createdWindows: Array<{ sessionName: string; windowName: string; cwd: string; command?: string }> = [];
4041
readonly commands: Array<{ target: string; command: string }> = [];
4142

4243
ensureServer(): void {}
@@ -52,6 +53,7 @@ class FakeTmuxGateway implements TmuxGateway {
5253
}
5354

5455
createWindow(opts: { sessionName: string; windowName: string; cwd: string; command?: string }): void {
56+
this.createdWindows.push({ ...opts });
5557
this.windows.set(this.key(opts.sessionName, opts.windowName), {
5658
sessionName: opts.sessionName,
5759
windowName: opts.windowName,
@@ -972,6 +974,27 @@ describe("LifecycleService", () => {
972974
expect(state?.session.exists).toBe(true);
973975
});
974976

977+
it("starts one-pane docker agent sessions without nesting docker exec inside the container shell", async () => {
978+
const repoRoot = await initRepo();
979+
const runtime = new ProjectRuntime();
980+
const tmux = new FakeTmuxGateway();
981+
const docker = new FakeDockerGateway();
982+
const lifecycle = makeLifecycleService(repoRoot, tmux, runtime, docker);
983+
984+
await lifecycle.createWorktree({
985+
branch: "feature-sandbox-agent",
986+
profile: "sandbox",
987+
});
988+
989+
const windowCommand = tmux.createdWindows[0]?.command;
990+
const agentCommand = tmux.commands[0]?.command;
991+
992+
expect(windowCommand).toContain("docker exec -it");
993+
expect(windowCommand).toContain("wm-feature-sandbox-agent-container");
994+
expect(agentCommand).toContain("claude");
995+
expect(agentCommand).not.toContain("docker exec");
996+
});
997+
975998
it("reports backend creation phases in order until the worktree is ready", async () => {
976999
const repoRoot = await initRepo();
9771000
const runtime = new ProjectRuntime();

backend/src/services/agent-service.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { AgentKind } from "../domain/config";
22

33
export type AgentLaunchMode = "fresh" | "resume";
44

5+
const DOCKER_PATH_FALLBACK = "/root/.local/bin:/usr/local/bin:/root/.bun/bin:/root/.cargo/bin";
6+
57
function quoteShell(value: string): string {
68
return `'${value.replaceAll("'", "'\\''")}'`;
79
}
@@ -10,6 +12,10 @@ function buildRuntimeBootstrap(runtimeEnvPath: string): string {
1012
return `set -a; . ${quoteShell(runtimeEnvPath)}; set +a`;
1113
}
1214

15+
function buildDockerRuntimeBootstrap(runtimeEnvPath: string): string {
16+
return `${buildRuntimeBootstrap(runtimeEnvPath)}; export PATH="$PATH:${DOCKER_PATH_FALLBACK}"`;
17+
}
18+
1319
function buildAgentInvocation(input: {
1420
agent: AgentKind;
1521
yolo?: boolean;
@@ -49,16 +55,16 @@ function buildAgentCommand(input: {
4955
systemPrompt?: string;
5056
prompt?: string;
5157
launchMode?: AgentLaunchMode;
52-
}): string {
53-
return `${buildRuntimeBootstrap(input.runtimeEnvPath)}; ${buildAgentInvocation(input)}`;
58+
}, bootstrap = buildRuntimeBootstrap): string {
59+
return `${bootstrap(input.runtimeEnvPath)}; ${buildAgentInvocation(input)}`;
5460
}
5561

5662
function buildDockerExecCommand(
5763
containerName: string,
5864
worktreePath: string,
5965
command: string,
6066
): string {
61-
return `docker exec -it -w ${quoteShell(worktreePath)} ${quoteShell(containerName)} bash -lc ${quoteShell(command)}`;
67+
return `docker exec -it -w ${quoteShell(worktreePath)} ${quoteShell(containerName)} /bin/sh -c ${quoteShell(command)}`;
6268
}
6369

6470
export function buildManagedShellCommand(
@@ -83,28 +89,22 @@ export function buildDockerShellCommand(
8389
containerName: string,
8490
worktreePath: string,
8591
runtimeEnvPath: string,
86-
shellPath = Bun.env.SHELL || "/bin/bash",
92+
shellPath = "/bin/bash",
8793
): string {
8894
return buildDockerExecCommand(
8995
containerName,
9096
worktreePath,
91-
`${buildRuntimeBootstrap(runtimeEnvPath)}; exec ${quoteShell(shellPath)} -i`,
97+
`${buildDockerRuntimeBootstrap(runtimeEnvPath)}; if [ -x ${quoteShell(shellPath)} ]; then exec ${quoteShell(shellPath)} -i; elif [ -x /bin/sh ]; then exec /bin/sh -i; else echo 'webmux: no shell found in container' >&2; exit 127; fi`,
9298
);
9399
}
94100

95101
export function buildDockerAgentPaneCommand(input: {
96102
agent: AgentKind;
97-
containerName: string;
98-
worktreePath: string;
99103
runtimeEnvPath: string;
100104
yolo?: boolean;
101105
systemPrompt?: string;
102106
prompt?: string;
103107
launchMode?: AgentLaunchMode;
104108
}): string {
105-
return buildDockerExecCommand(
106-
input.containerName,
107-
input.worktreePath,
108-
buildAgentCommand(input),
109-
);
109+
return buildAgentCommand(input, buildDockerRuntimeBootstrap);
110110
}

backend/src/services/lifecycle-service.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,8 +634,6 @@ export class LifecycleService {
634634
? {
635635
agent: buildDockerAgentPaneCommand({
636636
agent: input.agent,
637-
containerName,
638-
worktreePath: input.worktreePath,
639637
runtimeEnvPath: input.initialized.paths.runtimeEnvPath,
640638
yolo: input.profile.yolo === true,
641639
systemPrompt,

0 commit comments

Comments
 (0)