feat(container): per-agent runtime and state volume primitives #22

Merged
code-lead merged 1 commit from boss/19 into main 2026-04-17 15:00:08 +00:00
Collaborator

Summary

Lands the host-side plumbing for per-agent Docker containers with empty $HOME and a named state volume, so agents can no longer scrape host credentials (see the 2026-04-17 identity-leak incident, commit bab386a).

  • src/container.ts — naming (claude-hooks-<agent> / -state / -home), in-container path helpers, isContainerRunning, assertContainerRunning (fails fast; claude-hooks never auto-starts worker containers), buildDockerExecArgs, ensureExecShim for the SDK's pathToClaudeCodeExecutable, and assertCredentialsBindSafe to block a bind from the interactive user's ~/.claude/.credentials.json.
  • src/workdir.ts — threaded an optional GitRunner + cacheRootOverride through every public op, so container mode dispatches git through docker exec -w /state/... claude-hooks-<name> git ... and state lives in the volume instead of on the host.
  • src/main.tsWorkerConfig.container opts in; runAgent fails fast when the container is down, builds a container-aware GitRunner, points the SDK's pathToClaudeCodeExecutable at the shim, and exports the shim's env contract (CLAUDE_HOOKS_CONTAINER / _DOCKER_CWD / _DOCKER_ENV). Claude credentials bind target, $HOME volume layout, and "no host Docker socket" rule are documented on the code paths that reference them.
  • src/webhook.ts + config loader — parse optional container.enabled / credentials_host_path / image per agent.
  • docs/decisions/containers.md — records the stdio-bridge choice (docker exec -i over Unix socket), the volume layout (/state/{repos,worktrees,sessions.json}), why the credentials bind must not be ~/.claude/.credentials.json, and the "no Docker socket inside the container" invariant.

Out of scope (tracked elsewhere):

  • Image build / publish → #18
  • docker run / systemd / rolling updates → #20
  • Migrating existing on-host state into volumes (new installs start clean)

Test plan

  • bunx tsc --noEmit — clean
  • bunx biome check src/ — clean
  • bun test — 85 tests pass (up from previous baseline) across container/main/webhook/workdir
  • just qa equivalents pass without a Docker daemon running — DockerRunner is mocked end-to-end for the container primitives and the runAgent fail-fast path (AC: host-only unit tests)
  • End-to-end inside a real container — deferred until #18 produces the image and #20 wires up the docker run invocation

Closes #19

## Summary Lands the host-side plumbing for per-agent Docker containers with empty `$HOME` and a named state volume, so agents can no longer scrape host credentials (see the 2026-04-17 identity-leak incident, commit `bab386a`). - `src/container.ts` — naming (`claude-hooks-<agent>` / `-state` / `-home`), in-container path helpers, `isContainerRunning`, `assertContainerRunning` (fails fast; claude-hooks never auto-starts worker containers), `buildDockerExecArgs`, `ensureExecShim` for the SDK's `pathToClaudeCodeExecutable`, and `assertCredentialsBindSafe` to block a bind from the interactive user's `~/.claude/.credentials.json`. - `src/workdir.ts` — threaded an optional `GitRunner` + `cacheRootOverride` through every public op, so container mode dispatches git through `docker exec -w /state/... claude-hooks-<name> git ...` and state lives in the volume instead of on the host. - `src/main.ts` — `WorkerConfig.container` opts in; `runAgent` fails fast when the container is down, builds a container-aware `GitRunner`, points the SDK's `pathToClaudeCodeExecutable` at the shim, and exports the shim's env contract (`CLAUDE_HOOKS_CONTAINER` / `_DOCKER_CWD` / `_DOCKER_ENV`). Claude credentials bind target, `$HOME` volume layout, and "no host Docker socket" rule are documented on the code paths that reference them. - `src/webhook.ts` + config loader — parse optional `container.enabled` / `credentials_host_path` / `image` per agent. - `docs/decisions/containers.md` — records the stdio-bridge choice (`docker exec -i` over Unix socket), the volume layout (`/state/{repos,worktrees,sessions.json}`), why the credentials bind must not be `~/.claude/.credentials.json`, and the "no Docker socket inside the container" invariant. Out of scope (tracked elsewhere): - Image build / publish → #18 - `docker run` / systemd / rolling updates → #20 - Migrating existing on-host state into volumes (new installs start clean) ## Test plan - [x] `bunx tsc --noEmit` — clean - [x] `bunx biome check src/` — clean - [x] `bun test` — 85 tests pass (up from previous baseline) across container/main/webhook/workdir - [x] `just qa` equivalents pass **without** a Docker daemon running — `DockerRunner` is mocked end-to-end for the container primitives and the `runAgent` fail-fast path (AC: host-only unit tests) - [ ] End-to-end inside a real container — deferred until #18 produces the image and #20 wires up the `docker run` invocation Closes #19
reviewer requested changes 2026-04-17 13:38:58 +00:00
Dismissed
reviewer left a comment

Review of PR #22 — per-agent runtime and state volume primitives

Overall the architecture is solid: naming helpers, pluggable DockerRunner, assertContainerRunning fail-fast, shim-based pathToClaudeCodeExecutable, and the WorkdirOpts threading through workdir ops all look correct and well-tested.

Two real bugs that need fixing before merge:

  1. Corrupt cache recovery in container mode is broken — see inline comment on src/workdir.ts
  2. GIT_ASKPASS forwarded to the container points at a host path — see inline comment on src/main.ts

There is also a minor duplication worth addressing.

AC coverage

All in-scope items are met:

  • Naming helpers, assertContainerRunning, fail-fast:
  • WorkdirOpts / GitRunner threaded through every workdir op:
  • ensureExecShim + pathToClaudeCodeExecutable:
  • assertCredentialsBindSafe:
  • Config loader for container.enabled / credentials_host_path / image:
  • just qa without Docker daemon (mocked DockerRunner):

Integration tests (spawn/kill real container) correctly deferred to #18/#20 per the issue's own blocked-by list.

## Review of PR #22 — per-agent runtime and state volume primitives Overall the architecture is solid: naming helpers, pluggable `DockerRunner`, `assertContainerRunning` fail-fast, shim-based `pathToClaudeCodeExecutable`, and the `WorkdirOpts` threading through workdir ops all look correct and well-tested. Two real bugs that need fixing before merge: 1. **Corrupt cache recovery in container mode is broken** — see inline comment on `src/workdir.ts` 2. **`GIT_ASKPASS` forwarded to the container points at a host path** — see inline comment on `src/main.ts` There is also a minor duplication worth addressing. ### AC coverage All in-scope items are met: - Naming helpers, `assertContainerRunning`, fail-fast: ✅ - `WorkdirOpts` / `GitRunner` threaded through every workdir op: ✅ - `ensureExecShim` + `pathToClaudeCodeExecutable`: ✅ - `assertCredentialsBindSafe`: ✅ - Config loader for `container.enabled / credentials_host_path / image`: ✅ - `just qa` without Docker daemon (mocked `DockerRunner`): ✅ Integration tests (spawn/kill real container) correctly deferred to #18/#20 per the issue's own blocked-by list.
src/main.ts Outdated
@ -369,0 +400,4 @@
const hostDockerRunner: DockerRunner = {
async run(args) {
const proc = Bun.spawn(["docker", ...args], { stdout: "pipe", stderr: "pipe" });
Collaborator

Minor: hostDockerRunner here is identical to makeDefaultRunner() in src/container.ts.

These will drift independently (e.g. if error handling or Bun API changes). Consider exporting makeDefaultRunner from container.ts and importing it here, or just importing the unexported implementation through a thin export.

Not a blocker, but worth a follow-up.

**Minor: `hostDockerRunner` here is identical to `makeDefaultRunner()` in `src/container.ts`.** These will drift independently (e.g. if error handling or Bun API changes). Consider exporting `makeDefaultRunner` from `container.ts` and importing it here, or just importing the unexported implementation through a thin export. Not a blocker, but worth a follow-up.
src/main.ts Outdated
@ -369,0 +412,4 @@
/**
* Build a GitRunner that runs `git` inside `container` via `docker exec`.
* Exported for testability; `runAgent` wires this when `container.enabled`.
Collaborator

Bug: GIT_ASKPASS forwarded to the container still points at the host-side script path.

makeContainerGitRunner tries to default GIT_ASKPASS to CONTAINER_ASKPASS_PATH (/state/askpass.sh):

if (env.GIT_ASKPASS === undefined) env.GIT_ASKPASS = CONTAINER_ASKPASS_PATH;

But every caller in workdir.ts passes gitAuthEnv(agent) as opts.env, which already includes GIT_ASKPASS set to the host ASKPASS path (e.g. ~/.cache/claude-hooks/askpass.sh). So the === undefined guard never fires; docker exec receives -e GIT_ASKPASS=/home/charles/.cache/claude-hooks/askpass.sh and git auth inside the container fails for any private-repo operation.

The test for makeContainerGitRunner passes { GIT_AUTHOR_NAME: 'Boss' } (no GIT_ASKPASS), so this path is never exercised under test.

Fix: override unconditionally when in container mode, or strip GIT_ASKPASS from gitAuthEnv's output before passing it to the git runner, or have workdir.ts pass the in-container ASKPASS path explicitly when opts.runner is a container runner.

**Bug: `GIT_ASKPASS` forwarded to the container still points at the host-side script path.** `makeContainerGitRunner` tries to default `GIT_ASKPASS` to `CONTAINER_ASKPASS_PATH` (`/state/askpass.sh`): ```typescript if (env.GIT_ASKPASS === undefined) env.GIT_ASKPASS = CONTAINER_ASKPASS_PATH; ``` But every caller in `workdir.ts` passes `gitAuthEnv(agent)` as `opts.env`, which already includes `GIT_ASKPASS` set to the host ASKPASS path (e.g. `~/.cache/claude-hooks/askpass.sh`). So the `=== undefined` guard never fires; docker exec receives `-e GIT_ASKPASS=/home/charles/.cache/claude-hooks/askpass.sh` and git auth inside the container fails for any private-repo operation. The test for `makeContainerGitRunner` passes `{ GIT_AUTHOR_NAME: 'Boss' }` (no `GIT_ASKPASS`), so this path is never exercised under test. Fix: override unconditionally when in container mode, or strip `GIT_ASKPASS` from `gitAuthEnv`'s output before passing it to the git runner, or have `workdir.ts` pass the in-container ASKPASS path explicitly when `opts.runner` is a container runner.
src/workdir.ts Outdated
@ -214,3 +271,3 @@
} catch (err) {
console.warn(`[workdir] cache ${path} looks corrupt — re-cloning (${(err as Error).message})`);
await rm(path, { recursive: true, force: true });
if (onHost) {
Collaborator

Bug: corrupt cache recovery in container mode doesn't delete the directory and will cause the re-clone to fail.

runner.run(['-C', '/', 'clean', '-fdx'], { cwd: path }) dispatches git -C / clean -fdx inside the container. The -C / flag overrides git's working directory to /; git clean at / will either fail with "not a git repository" or, if the root happens to be a git tree, do something catastrophic. Either way the corrupt directory at path is not deleted. The subsequent git clone ... path then fails because the destination already exists.

The host-side equivalent is rm -rf path. The container-side equivalent must also delete the directory. Since GitRunner only speaks git, you need to break out of it — either:

// Option A: shell rm via docker exec directly (bypass GitRunner)
await dockerRunner.run(["exec", containerName, "rm", "-rf", path]).catch(() => {});

or restructure _doEnsureCacheClone to accept an optional rimraf-style callback for the container case, keeping GitRunner pure. Whatever shape you choose, the key invariant is that path must not exist before the clone runs.

**Bug: corrupt cache recovery in container mode doesn't delete the directory and will cause the re-clone to fail.** `runner.run(['-C', '/', 'clean', '-fdx'], { cwd: path })` dispatches `git -C / clean -fdx` inside the container. The `-C /` flag overrides git's working directory to `/`; `git clean` at `/` will either fail with "not a git repository" or, if the root happens to be a git tree, do something catastrophic. Either way the corrupt directory at `path` is **not deleted**. The subsequent `git clone ... path` then fails because the destination already exists. The host-side equivalent is `rm -rf path`. The container-side equivalent must also delete the directory. Since `GitRunner` only speaks git, you need to break out of it — either: ```typescript // Option A: shell rm via docker exec directly (bypass GitRunner) await dockerRunner.run(["exec", containerName, "rm", "-rf", path]).catch(() => {}); ``` or restructure `_doEnsureCacheClone` to accept an optional `rimraf`-style callback for the container case, keeping `GitRunner` pure. Whatever shape you choose, the key invariant is that `path` must not exist before the clone runs.
Author
Collaborator

Addressed in bb52d06.

1. Corrupt cache recovery (src/workdir.ts) — agreed, git -C / clean -fdx was wrong on every axis (doesn't delete the directory, behaves badly at /). Extended WorkdirOpts with a removePath?: (path: string) => Promise<void> callback (host rm -rf by default) and wired container mode to a new exported makeContainerRemovePath() that dispatches docker exec <container> rm -rf -- <path>. Also reused the same callback in releaseWorktree so container-mode worktree cleanup deletes inside the volume consistently. Regression test in workdir.test.ts mocks a runner that makes git status fail and asserts removePath is invoked with the cache path before the re-clone.

2. GIT_ASKPASS host path leaks into the container (src/main.ts) — agreed, the === undefined guard never fires because every caller passes gitAuthEnv(agent) which sets the host path. Changed to an unconditional override to CONTAINER_ASKPASS_PATH (/state/askpass.sh). The test in main.test.ts now passes the host ASKPASS path explicitly in the env and asserts the docker exec argv carries GIT_ASKPASS=/state/askpass.sh (and does not carry the host path).

3. hostDockerRunner duplication (src/main.ts) — exported makeDefaultDockerRunner from src/container.ts and reused it in main.ts. Single implementation now.

just qa equivalents: bunx tsc --noEmit clean, bunx biome check src/ clean, bun test 89/89 pass (+4 from this round).

Addressed in bb52d06. **1. Corrupt cache recovery (`src/workdir.ts`)** — agreed, `git -C / clean -fdx` was wrong on every axis (doesn't delete the directory, behaves badly at `/`). Extended `WorkdirOpts` with a `removePath?: (path: string) => Promise<void>` callback (host `rm -rf` by default) and wired container mode to a new exported `makeContainerRemovePath()` that dispatches `docker exec <container> rm -rf -- <path>`. Also reused the same callback in `releaseWorktree` so container-mode worktree cleanup deletes inside the volume consistently. Regression test in `workdir.test.ts` mocks a runner that makes `git status` fail and asserts `removePath` is invoked with the cache path **before** the re-clone. **2. `GIT_ASKPASS` host path leaks into the container (`src/main.ts`)** — agreed, the `=== undefined` guard never fires because every caller passes `gitAuthEnv(agent)` which sets the host path. Changed to an unconditional override to `CONTAINER_ASKPASS_PATH` (`/state/askpass.sh`). The test in `main.test.ts` now passes the host ASKPASS path explicitly in the env and asserts the docker exec argv carries `GIT_ASKPASS=/state/askpass.sh` (and does **not** carry the host path). **3. `hostDockerRunner` duplication (`src/main.ts`)** — exported `makeDefaultDockerRunner` from `src/container.ts` and reused it in `main.ts`. Single implementation now. `just qa` equivalents: `bunx tsc --noEmit` clean, `bunx biome check src/` clean, `bun test` 89/89 pass (+4 from this round).
reviewer requested changes 2026-04-17 14:37:18 +00:00
Dismissed
reviewer left a comment

Two bugs to fix before merge. Everything else is solid — the GitRunner abstraction is clean, the DockerRunner injection is correctly wired through assertContainerRunning vs. makeContainerGitRunner/makeContainerRemovePath, the shim contract is well-documented, and the AC items deferred to #18/#20 are clearly marked. The test coverage for the host-only unit path is good.

Requesting changes on two issues: one functional bug that will break every git push from inside the container, and one fragile shell quoting pattern in the generated shim.


Note on deferred integration tests: The two [ ] items in the test plan (spawn container + run task; kill/restart mid-task) are explicitly blocked on #18's image. That's fine and documented. Just flagging so they don't get forgotten when #18 lands.

Two bugs to fix before merge. Everything else is solid — the `GitRunner` abstraction is clean, the `DockerRunner` injection is correctly wired through `assertContainerRunning` vs. `makeContainerGitRunner`/`makeContainerRemovePath`, the shim contract is well-documented, and the AC items deferred to #18/#20 are clearly marked. The test coverage for the host-only unit path is good. Requesting changes on two issues: one functional bug that will break every git push from inside the container, and one fragile shell quoting pattern in the generated shim. --- **Note on deferred integration tests**: The two `[ ]` items in the test plan (spawn container + run task; kill/restart mid-task) are explicitly blocked on #18's image. That's fine and documented. Just flagging so they don't get forgotten when #18 lands.
src/container.ts Outdated
@ -0,0 +227,4 @@
if (opts.cwd) args.push("-w", opts.cwd);
if (opts.env) {
for (const [key, value] of Object.entries(opts.env)) {
if (value === undefined) continue;
Collaborator

Fragile: unquoted $CLAUDE_HOOKS_DOCKER_CWD in docker_flags will word-split if the path ever contains a space.

The current shim:

docker_flags="exec -i -w $CLAUDE_HOOKS_DOCKER_CWD"
...
exec docker $docker_flags "$CLAUDE_HOOKS_CONTAINER" ...

If CLAUDE_HOOKS_DOCKER_CWD = /state/worktrees/boss/foo bar, docker receives: exec -i -w /state/worktrees/boss/foo bar claude-hooks-boss ...bar is interpreted as the container name, -w gets only foo.

Container paths today are space-free (%2F encoded), so this won't bite right now. But the shim is a security-sensitive generated artifact, and shell quoting bugs in docker exec wrappers are exactly the kind of thing that quietly misbehaves under unexpected input.

Fix — stop accumulating flags in a string variable; build the -w arg separately so it can be quoted:

#!/bin/sh
set -eu
: "${CLAUDE_HOOKS_CONTAINER:?CLAUDE_HOOKS_CONTAINER not set}"
: "${CLAUDE_HOOKS_DOCKER_CWD:?CLAUDE_HOOKS_DOCKER_CWD not set}"
extra_e=""
for name in ${CLAUDE_HOOKS_DOCKER_ENV:-}; do
  extra_e="$extra_e -e $name"
done
# shellcheck disable=SC2086
exec docker exec -i -w "$CLAUDE_HOOKS_DOCKER_CWD" $extra_e "$CLAUDE_HOOKS_CONTAINER" /usr/local/bin/claude "$@"

The -w "$CLAUDE_HOOKS_DOCKER_CWD" is now a single quoted word. $extra_e is still unquoted (intentional word-split for the -e NAME pairs), which SC2086 already covers.

**Fragile: unquoted `$CLAUDE_HOOKS_DOCKER_CWD` in `docker_flags` will word-split if the path ever contains a space.** The current shim: ```sh docker_flags="exec -i -w $CLAUDE_HOOKS_DOCKER_CWD" ... exec docker $docker_flags "$CLAUDE_HOOKS_CONTAINER" ... ``` If `CLAUDE_HOOKS_DOCKER_CWD` = `/state/worktrees/boss/foo bar`, docker receives: `exec -i -w /state/worktrees/boss/foo bar claude-hooks-boss ...` — `bar` is interpreted as the container name, `-w` gets only `foo`. Container paths today are space-free (`%2F` encoded), so this won't bite right now. But the shim is a security-sensitive generated artifact, and shell quoting bugs in docker exec wrappers are exactly the kind of thing that quietly misbehaves under unexpected input. Fix — stop accumulating flags in a string variable; build the `-w` arg separately so it can be quoted: ```sh #!/bin/sh set -eu : "${CLAUDE_HOOKS_CONTAINER:?CLAUDE_HOOKS_CONTAINER not set}" : "${CLAUDE_HOOKS_DOCKER_CWD:?CLAUDE_HOOKS_DOCKER_CWD not set}" extra_e="" for name in ${CLAUDE_HOOKS_DOCKER_ENV:-}; do extra_e="$extra_e -e $name" done # shellcheck disable=SC2086 exec docker exec -i -w "$CLAUDE_HOOKS_DOCKER_CWD" $extra_e "$CLAUDE_HOOKS_CONTAINER" /usr/local/bin/claude "$@" ``` The `-w "$CLAUDE_HOOKS_DOCKER_CWD"` is now a single quoted word. `$extra_e` is still unquoted (intentional word-split for the `-e NAME` pairs), which SC2086 already covers.
src/main.ts Outdated
Collaborator

Bug: GIT_ASKPASS forwarded to the container points at the host path, breaking all authenticated git ops inside the container.

gitAuthEnv(agent) sets GIT_ASKPASS to the host ASKPASS script (e.g. /home/charles/.cache/claude-hooks/askpass.sh). That path doesn't exist inside the container. When Claude uses the Bash tool to run git push/git fetch, git will try to exec the host path, get no such file, and fail to authenticate.

You already fixed this exact problem in makeContainerGitRunner (the regression test even calls it out: "override is unconditional"). Apply the same fix to baseEnv:

const baseEnv: Record<string, string | undefined> = {
  CLAUDE_CONFIG_DIR: containerMode ? CONTAINER_CLAUDE_CONFIG_DIR : ISOLATED_CONFIG_DIR,
  ...gitIdentityEnv(agent),
  ...gitAuthEnv(agent),
  ...(containerMode ? { GIT_ASKPASS: CONTAINER_ASKPASS_PATH } : {}),
  FORGEJO_ACCESS_TOKEN: this.config.forgejo_token,
};

CONTAINER_ASKPASS_PATH is already defined in this file as ${CONTAINER_STATE_ROOT}/askpass.sh. The fix also needs a test: add a case to the Worker container mode describe block that checks CLAUDE_HOOKS_DOCKER_ENV ultimately contains GIT_ASKPASS=/state/askpass.sh (not the host path) when container mode is active.

**Bug: `GIT_ASKPASS` forwarded to the container points at the host path, breaking all authenticated git ops inside the container.** `gitAuthEnv(agent)` sets `GIT_ASKPASS` to the host ASKPASS script (e.g. `/home/charles/.cache/claude-hooks/askpass.sh`). That path doesn't exist inside the container. When Claude uses the `Bash` tool to run `git push`/`git fetch`, git will try to exec the host path, get `no such file`, and fail to authenticate. You already fixed this exact problem in `makeContainerGitRunner` (the regression test even calls it out: "override is unconditional"). Apply the same fix to `baseEnv`: ```typescript const baseEnv: Record<string, string | undefined> = { CLAUDE_CONFIG_DIR: containerMode ? CONTAINER_CLAUDE_CONFIG_DIR : ISOLATED_CONFIG_DIR, ...gitIdentityEnv(agent), ...gitAuthEnv(agent), ...(containerMode ? { GIT_ASKPASS: CONTAINER_ASKPASS_PATH } : {}), FORGEJO_ACCESS_TOKEN: this.config.forgejo_token, }; ``` `CONTAINER_ASKPASS_PATH` is already defined in this file as `${CONTAINER_STATE_ROOT}/askpass.sh`. The fix also needs a test: add a case to the `Worker container mode` describe block that checks `CLAUDE_HOOKS_DOCKER_ENV` ultimately contains `GIT_ASKPASS=/state/askpass.sh` (not the host path) when container mode is active.
Author
Collaborator

Addressed in e095fd9.

1. GIT_ASKPASS leaks into the SDK env (src/main.ts) — agreed. The makeContainerGitRunner override only covered direct runAgent → GitRunner calls; the env passed to query({ options: { env } }) still carried gitAuthEnv(agent)'s host path, so when the Claude CLI ran git itself inside the container (via the Bash tool) the shim forwarded a broken GIT_ASKPASS. Extracted env assembly into an exported buildAgentEnv helper and added an unconditional override to CONTAINER_ASKPASS_PATH in container mode. Applied exactly the spread-then-override pattern you suggested. New test buildAgentEnv overrides host GIT_ASKPASS with the in-container path in container mode asserts env.GIT_ASKPASS === "/state/askpass.sh" and that no host path survives; a companion test covers the non-container path.

2. Fragile unquoted $CLAUDE_HOOKS_DOCKER_CWD in the shim (src/container.ts) — agreed. Rewrote ensureExecShim along the lines you suggested: -w "$CLAUDE_HOOKS_DOCKER_CWD" and "$CLAUDE_HOOKS_CONTAINER" are single quoted words, $extra_e stays unquoted for the -e NAME word-splitting (SC2086 disabled), no more docker_flags= accumulator. Also made ensureExecShim rewrite when on-disk content differs so deployed instances with an older shim pick up the new one on next run. New tests: shim quotes the docker -w cwd argument so paths with spaces are safe and shim is rewritten when existing content differs.

just qa equivalents: bunx tsc --noEmit clean, bunx biome check src/ clean, bun test 93/93 pass (+4 from this round).

Addressed in e095fd9. **1. `GIT_ASKPASS` leaks into the SDK env (`src/main.ts`)** — agreed. The `makeContainerGitRunner` override only covered direct `runAgent → GitRunner` calls; the env passed to `query({ options: { env } })` still carried `gitAuthEnv(agent)`'s host path, so when the Claude CLI ran git itself inside the container (via the Bash tool) the shim forwarded a broken `GIT_ASKPASS`. Extracted env assembly into an exported `buildAgentEnv` helper and added an unconditional override to `CONTAINER_ASKPASS_PATH` in container mode. Applied exactly the spread-then-override pattern you suggested. New test `buildAgentEnv overrides host GIT_ASKPASS with the in-container path in container mode` asserts `env.GIT_ASKPASS === "/state/askpass.sh"` and that no host path survives; a companion test covers the non-container path. **2. Fragile unquoted `$CLAUDE_HOOKS_DOCKER_CWD` in the shim (`src/container.ts`)** — agreed. Rewrote `ensureExecShim` along the lines you suggested: `-w "$CLAUDE_HOOKS_DOCKER_CWD"` and `"$CLAUDE_HOOKS_CONTAINER"` are single quoted words, `$extra_e` stays unquoted for the `-e NAME` word-splitting (SC2086 disabled), no more `docker_flags=` accumulator. Also made `ensureExecShim` rewrite when on-disk content differs so deployed instances with an older shim pick up the new one on next run. New tests: `shim quotes the docker -w cwd argument so paths with spaces are safe` and `shim is rewritten when existing content differs`. `just qa` equivalents: `bunx tsc --noEmit` clean, `bunx biome check src/` clean, `bun test` 93/93 pass (+4 from this round).
reviewer approved these changes 2026-04-17 14:42:29 +00:00
Dismissed
reviewer left a comment

Both requested changes are in.

GIT_ASKPASS bug — Fixed cleanly. buildAgentEnv is now an exported function that unconditionally overrides GIT_ASKPASS with CONTAINER_ASKPASS_PATH in container mode, and the regression test explicitly asserts the host path doesn't survive. Bonus: extracting buildAgentEnv makes the env construction independently testable going forward.

Shim quoting — Fixed. -w "$CLAUDE_HOOKS_DOCKER_CWD" is now a properly quoted word; $extra_e remains intentionally unquoted for word-splitting. The shim comment documents both decisions. Also noticed ensureExecShim now does a content check and rewrites on mismatch — that's the right call, no more stale shims after upgrades.

LGTM.

Both requested changes are in. **GIT_ASKPASS bug** — Fixed cleanly. `buildAgentEnv` is now an exported function that unconditionally overrides `GIT_ASKPASS` with `CONTAINER_ASKPASS_PATH` in container mode, and the regression test explicitly asserts the host path doesn't survive. Bonus: extracting `buildAgentEnv` makes the env construction independently testable going forward. **Shim quoting** — Fixed. `-w "$CLAUDE_HOOKS_DOCKER_CWD"` is now a properly quoted word; `$extra_e` remains intentionally unquoted for word-splitting. The shim comment documents both decisions. Also noticed `ensureExecShim` now does a content check and rewrites on mismatch — that's the right call, no more stale shims after upgrades. LGTM.
code-lead force-pushed boss/19 from e095fd9a20
All checks were successful
qa / qa (pull_request) Successful in 46s
to d314ee77fa
All checks were successful
qa / qa (pull_request) Successful in 45s
2026-04-17 14:55:08 +00:00
Compare
reviewer approved these changes 2026-04-17 14:59:37 +00:00
reviewer left a comment

Rebase-only update — the diff grew slightly because main advanced (sessions work merged), shifting context lines. The code introduced by this PR is identical to the previously approved version. Nothing new to flag.

Rebase-only update — the diff grew slightly because main advanced (sessions work merged), shifting context lines. The code introduced by this PR is identical to the previously approved version. Nothing new to flag.
code-lead deleted branch boss/19 2026-04-17 15:00:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!22
No description provided.