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
+29 -5
View File
@@ -195,6 +195,8 @@ export interface CloneOptions {
/** `--depth 1`: history-less but much faster on large repos. */
shallow?: boolean;
timeoutMs?: number;
/** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */
withoutCredentialHelpers?: boolean;
}
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
@@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean {
// ─── Pure: argv + env ────────────────────────────────────────────────────────
/**
* Global git options that empty the credential-helper list for one run.
*
* Every Codeman user in multi-user mode runs git as the SAME OS account, so a
* helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a
* user's own `gh auth setup-git`) would read private repositories on the
* signed-in admin's behalf for anyone who can reach Clone Repo. An empty
* `credential.helper` resets the helper list, and a command-line `-c` is read
* last, so it also drops the URL-scoped `credential.<url>.helper` entries the
* image configures (verified against a real private repo: refs with the helper,
* `could not read Username` with it cleared). Public repositories are
* unaffected. It must precede the subcommand.
*/
export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper='];
/**
* argv for the clone. `--` separates flags from operands so neither the
* repository nor the destination can ever be read as an option.
*/
export function buildCloneArgs(opts: CloneOptions): string[] {
const args = ['clone'];
const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone'];
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
if (opts.shallow) args.push('--depth', '1');
@@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] {
}
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
export function buildLsRemoteArgs(repository: string): string[] {
return ['ls-remote', '--symref', '--', repository];
export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] {
return [
...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []),
'ls-remote',
'--symref',
'--',
repository,
];
}
/**
@@ -790,7 +813,8 @@ export function isGitAvailable(): boolean {
*/
export async function probeGitRemote(
repository: string,
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS,
opts: { withoutCredentialHelpers?: boolean } = {}
): Promise<GitRemoteProbe> {
if (!isGitAvailable()) {
return {
@@ -800,7 +824,7 @@ export async function probeGitRemote(
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
};
}
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES);
if (run.code !== 0 || run.spawnError) {
return {
reachable: false,