mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #472 from opticon454/feature/git-host-auth-clis
feat(docker): opt-in gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos
This commit is contained in:
+76
-5
@@ -606,9 +606,36 @@ export function agentImageNpmPackages(): string[] {
|
||||
return packages;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. */
|
||||
export function agentImageBuildArgPairs(): Array<[string, string]> {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]];
|
||||
/**
|
||||
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
|
||||
* ⚠️ Mirrors `GIT_HOST_CLI_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||
*/
|
||||
export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||
* as before these existed; anything other than 0/1 is refused rather than guessed at.
|
||||
*/
|
||||
export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
|
||||
const pairs: Array<[string, string]> = [];
|
||||
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
|
||||
const value = env[envName];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (value !== '0' && value !== '1') {
|
||||
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
pairs.push([argName, value]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
@@ -785,6 +812,14 @@ interface CredStorePolicy {
|
||||
seedFiles?: string[];
|
||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||
seedWhole?: boolean;
|
||||
/**
|
||||
* Seed this store ONLY when this environment variable is exactly `1`, read when
|
||||
* the container is created. For credentials that belong to an opt-in tool rather
|
||||
* than to an agent CLI every case already trusts: they are not inert just because
|
||||
* the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is
|
||||
* usable by anything in the container, and the agent in it is prompt-injectable).
|
||||
*/
|
||||
enabledByEnv?: string;
|
||||
}
|
||||
|
||||
const CRED_STORES: CredStorePolicy[] = [
|
||||
@@ -829,6 +864,31 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
},
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
// GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the
|
||||
// Docker server image, a headless Linux host), `config.yml` the preferences. An
|
||||
// agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials
|
||||
// through `gh`, so this seed is what lets an agent clone/push a private repo. A
|
||||
// token that lives in a desktop keyring is not in `hosts.yml` and does not carry
|
||||
// in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch
|
||||
// that builds gh into the agent image is on, never merely because the file exists.
|
||||
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' },
|
||||
// Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`,
|
||||
// `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is
|
||||
// needed to authenticate; the agent image carries its own extensions outside HOME.
|
||||
// `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so
|
||||
// this carries a sign-in from the Docker server image or a Linux host.
|
||||
// OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account.
|
||||
{
|
||||
rel: '.azure',
|
||||
enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ',
|
||||
seedFiles: [
|
||||
'azureProfile.json',
|
||||
'msal_token_cache.json',
|
||||
'service_principal_entries.json',
|
||||
'clouds.config',
|
||||
'config',
|
||||
],
|
||||
},
|
||||
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
|
||||
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
|
||||
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
|
||||
@@ -853,10 +913,14 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||
*/
|
||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
||||
export function resolveDockerCredentialArtifacts(
|
||||
home: string = homedir(),
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
for (const store of CRED_STORES) {
|
||||
if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue;
|
||||
const hostBase = join(home, store.rel);
|
||||
if (!existsSync(hostBase)) continue;
|
||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||
@@ -1136,10 +1200,17 @@ function buildAgentImage(
|
||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||
});
|
||||
}
|
||||
let buildArgPairs: Array<[string, string]>;
|
||||
try {
|
||||
buildArgPairs = agentImageBuildArgPairs();
|
||||
} catch (err) {
|
||||
// A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure.
|
||||
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
|
||||
}
|
||||
const argv = dockerEngineArgv(docker);
|
||||
const args = [
|
||||
...argv.slice(1),
|
||||
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()),
|
||||
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, buildArgPairs),
|
||||
];
|
||||
return new Promise<EnsureImageResult>((resolve) => {
|
||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||
|
||||
+30
-6
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,7 +602,7 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
return {
|
||||
code: 'AUTH_REQUIRED',
|
||||
message:
|
||||
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
|
||||
"That repository needs authentication. Codeman never asks for credentials, so sign this server's git in first (for example `gh auth login` or `az login` from a shell session; the Docker image can include both, see docker/README.md), or clone it outside Codeman and add it with Link Existing.",
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user