fix(docker): gate gh/az seeding on its switch; no shared git sign-in for non-admin clones

Addresses the review on #472.

- CRED_STORES: `.config/gh` and `.azure` now carry `enabledByEnv`
  (CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ), and resolveDockerCredentialArtifacts
  skips a store unless that variable is exactly `1`, read at container
  create. A host that merely has ~/.config/gh/hosts.yml or a plaintext MSAL
  cache no longer copies them into every case container. Tests: the default
  environment seeds neither even with the files present, and each store
  follows only its own switch.
- Multi-user mode: a non-admin's Clone Repo clone and preflight run with
  `git -c credential.helper=` (GIT_NO_CREDENTIAL_HELPERS, placed before the
  subcommand), so the server account's helpers are never lent to them.
  Verified against a real private repo that it also clears the URL-scoped
  credential.<url>.helper entries, and that public clones still work.
  Tests: the argv in test/git-clone.test.ts, and the route decision
  (non-admin cleared; admin and single-user kept) in
  test/routes/case-clone-credential-helpers.test.ts.
- Docs: recreate the case container to pick up seeds (docker/README.md,
  Docker-Cases wiki, docker-cases.md); the multi-user behaviour in
  docker/README.md and security-architecture.md; "functionally unchanged"
  instead of "unchanged" for an image built with both switches off
  (server.Dockerfile comment, README, changeset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
This commit is contained in:
Devvyn
2026-09-23 14:44:26 +08:00
co-authored by Claude Opus 5.5
parent 5cf5a45438
commit 02e40f506b
13 changed files with 250 additions and 35 deletions
+20 -3
View File
@@ -128,6 +128,18 @@ const APP_VERSION = (() => {
const LOCAL_CLONE_ADMIN_ONLY =
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
/**
* Whether a clone or preflight must run with git's credential helpers cleared:
* a non-admin in multi-user mode. Every user's git runs as the one server
* account, so its helpers (the Docker image's opt-in `gh`/`az` ones, or any
* `gh auth setup-git`) would otherwise read a private repository with the
* signed-in admin's credentials, the same boundary the local-transport rule
* above guards. Admins and single-user mode keep the account's own helpers.
*/
export function cloneWithoutCredentialHelpers(req: FastifyRequest): boolean {
return isMultiUserMode() && !isAdmin(req);
}
/**
* The one line of git's stderr worth appending to an error message.
*
@@ -475,7 +487,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!isGitAvailable()) {
return { success: true, data: { parse: parsed, gitAvailable: false } };
}
const remote = await probeGitRemote(parsed.repository);
const remote = await probeGitRemote(parsed.repository, undefined, {
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
});
return { success: true, data: { parse: parsed, remote, gitAvailable: true } };
}
);
@@ -491,8 +505,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
* request died mid-clone still sees the case appear over SSE when git finishes.
*
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
* this writes only inside the caller's own `resolveCasesDir`. The one exception
* is a `local`-transport source, which would read through that boundary.
* this writes only inside the caller's own `resolveCasesDir`. Two things would
* otherwise read through that boundary: a `local`-transport source (refused for
* non-admins) and the server account's git credential helpers, which every user
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
*
* Repository contents win over scaffolding: an existing CLAUDE.md is left
* alone, and hooks are MERGED into whatever `.claude/settings.local.json` the
@@ -562,6 +578,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
});