Bind-mount credentials directory, not file (survive claude login without container restart) #57

Closed
opened 2026-04-18 17:33:45 +00:00 by claude-desktop · 0 comments
Collaborator

User story

As a maintainer, I want agent containers to pick up refreshed Claude OAuth credentials without a restart, so that every claude login on the host doesn't silently break every running agent container for the next 8 h.

Context

Seen on #56 today. Sequence of events:

  1. Containers boot; Docker bind-mounts ~/.config/claude-hooks/claude-credentials/.credentials.json into /home/claude/.config/claude-code/.credentials.json. The mount binds to the inode at that path.
  2. 8 h later the host-side OAuth access token expires.
  3. claude login (or any refresh path that writes .new + rename) replaces the host file with a new inode.
  4. The container's bind-mount still points to the original inode (now nlink=0 but kept alive because the container has it mounted). Container sees stale, expired token forever.
  5. Tasks fail with HTTP 401 · authentication_error until the container is restarted.

We worked around it today by docker restart-ing boss/dev/reviewer. We also set up a systemd path unit (~/.config/systemd/user/claude-creds-mirror.{path,service}) that does cat src > dst to preserve the inode on the mirror file — that helps when the host-side mirror is in-place-rewritten, but it cannot rescue us when claude login itself atomic-renames the source file upstream of the mirror.

The root cause is: bind-mounting a file is bind-mounting an inode. Bind-mounting the containing directory sees path-level changes.

Acceptance criteria

Container config

  • config/agents.json container spec takes a directory path (credentials_host_dir) instead of / alongside credentials_host_path, and the runtime mounts that directory read-only at /home/claude/.config/claude-code/ (or wherever the SDK expects it).
  • The directory ~/.config/claude-hooks/claude-credentials/ contains only .credentials.json (already the case) — no accidental leakage of other secrets.
  • Read-only mount is preserved (no cred writes from inside the container).

Container launcher

  • Update the container-spawn code (src/workers/ or wherever docker run is assembled) to emit --mount type=bind,source=<dir>,target=<dir>,readonly instead of the current file-level mount.
  • Backwards-compat: if only credentials_host_path is set (old config), resolve to its parent dir automatically so nobody has to edit the config for the migration.

Verification

  • After landing, simulate the refresh:
    1. docker inspect $container --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}' should show the directory.
    2. Rewrite ~/.claude/.credentials.json via any method (atomic rename, in-place, doesn't matter).
    3. Mirror file (~/.config/claude-hooks/claude-credentials/.credentials.json) gets its new inode.
    4. docker exec $container stat -c %i /home/claude/.config/claude-code/.credentials.json now shows the host-current inode — no restart needed.
    5. A dispatched task authenticates with the fresh token.

Tests

  • Unit / integration: a mount-spec builder test that given credentials_host_path emits a directory mount for the parent.
  • Manual smoke test script in scripts/ that rewrites creds on the host, then calls docker exec on each agent container to verify the fresh inode is visible.

Docs

  • CLAUDE.md: note that credentials are directory-mounted, and claude login on the host is picked up automatically by running containers.
  • Update the claude-creds-mirror.service comment in memory / scripts/setup to explain it's no longer strictly required (but still useful so the mirror stays in sync).

Out of scope

  • Writing creds back from the container (SDK auto-refresh path) — still read-only.
  • Moving credentials into a secrets-manager backend (Vault / sops) — that's a separate story.
  • Rotating the refresh token itself — upstream Anthropic concern.

References

  • Debugging session on #56 (where we discovered the inode-orphan behavior and manually docker restart-ed the trio).
  • systemd path unit installed today: ~/.config/systemd/user/claude-creds-mirror.{path,service}.
  • Agent config the mount comes from: config/agents.jsonagents.*.container.credentials_host_path.
  • Memory note: ~/.claude/projects/-home-charles-Workspace-claude-hooks/memory/agents.md.

Dependencies

  • Blocked by: nothing.
  • Blocks: indirectly every long-running agent container survives a claude login without manual intervention; tightens the reliability story for #56 and every future agent type.
  • Branch off: main.
## User story As a **maintainer**, I want agent containers to pick up refreshed Claude OAuth credentials without a restart, so that every `claude login` on the host doesn't silently break every running agent container for the next 8 h. ## Context Seen on #56 today. Sequence of events: 1. Containers boot; Docker bind-mounts `~/.config/claude-hooks/claude-credentials/.credentials.json` into `/home/claude/.config/claude-code/.credentials.json`. The mount binds to the **inode** at that path. 2. 8 h later the host-side OAuth access token expires. 3. `claude login` (or any refresh path that writes `.new` + `rename`) replaces the host file **with a new inode**. 4. The container's bind-mount still points to the original inode (now `nlink=0` but kept alive because the container has it mounted). Container sees stale, expired token forever. 5. Tasks fail with `HTTP 401 · authentication_error` until the container is restarted. We worked around it today by `docker restart`-ing boss/dev/reviewer. We also set up a systemd path unit (`~/.config/systemd/user/claude-creds-mirror.{path,service}`) that does `cat src > dst` to preserve the inode on the mirror file — that helps when the host-side mirror is in-place-rewritten, but it cannot rescue us when `claude login` itself atomic-renames the source file upstream of the mirror. The root cause is: **bind-mounting a file is bind-mounting an inode**. Bind-mounting the containing **directory** sees path-level changes. ## Acceptance criteria ### Container config - [ ] `config/agents.json` container spec takes a **directory** path (`credentials_host_dir`) instead of / alongside `credentials_host_path`, and the runtime mounts that directory read-only at `/home/claude/.config/claude-code/` (or wherever the SDK expects it). - [ ] The directory `~/.config/claude-hooks/claude-credentials/` contains only `.credentials.json` (already the case) — no accidental leakage of other secrets. - [ ] Read-only mount is preserved (no cred writes from inside the container). ### Container launcher - [ ] Update the container-spawn code (`src/workers/` or wherever `docker run` is assembled) to emit `--mount type=bind,source=<dir>,target=<dir>,readonly` instead of the current file-level mount. - [ ] Backwards-compat: if only `credentials_host_path` is set (old config), resolve to its parent dir automatically so nobody has to edit the config for the migration. ### Verification - [ ] After landing, simulate the refresh: 1. `docker inspect $container --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'` should show the directory. 2. Rewrite `~/.claude/.credentials.json` via any method (atomic rename, in-place, doesn't matter). 3. Mirror file (`~/.config/claude-hooks/claude-credentials/.credentials.json`) gets its new inode. 4. `docker exec $container stat -c %i /home/claude/.config/claude-code/.credentials.json` now shows the **host-current** inode — no restart needed. 5. A dispatched task authenticates with the fresh token. ### Tests - [ ] Unit / integration: a mount-spec builder test that given `credentials_host_path` emits a directory mount for the parent. - [ ] Manual smoke test script in `scripts/` that rewrites creds on the host, then calls `docker exec` on each agent container to verify the fresh inode is visible. ### Docs - [ ] `CLAUDE.md`: note that credentials are directory-mounted, and `claude login` on the host is picked up automatically by running containers. - [ ] Update the `claude-creds-mirror.service` comment in memory / `scripts/setup` to explain it's no longer strictly required (but still useful so the mirror stays in sync). ## Out of scope - Writing creds back from the container (SDK auto-refresh path) — still read-only. - Moving credentials into a secrets-manager backend (Vault / sops) — that's a separate story. - Rotating the refresh token itself — upstream Anthropic concern. ## References - Debugging session on #56 (where we discovered the inode-orphan behavior and manually `docker restart`-ed the trio). - systemd path unit installed today: `~/.config/systemd/user/claude-creds-mirror.{path,service}`. - Agent config the mount comes from: `config/agents.json` → `agents.*.container.credentials_host_path`. - Memory note: `~/.claude/projects/-home-charles-Workspace-claude-hooks/memory/agents.md`. ## Dependencies - **Blocked by:** nothing. - **Blocks:** indirectly every long-running agent container survives a `claude login` without manual intervention; tightens the reliability story for #56 and every future agent type. - **Branch off:** `main`.
Sign in to join this conversation.
No project
No assignees
1 participant
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#57
No description provided.