diff --git a/src/agents/claude/_buildDockerArgs.spec.ts b/src/agents/claude/_buildDockerArgs.spec.ts new file mode 100644 index 0000000..e29f72f --- /dev/null +++ b/src/agents/claude/_buildDockerArgs.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { buildClaudeDockerArgs } from "./_buildDockerArgs.js"; +import type { ClaudeCredentials } from "./_resolveCredentials.js"; +import { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_OAUTH_TOKEN_ENV, +} from "./constants.js"; + +const SECRET = "sk-ant-oat01-super-secret"; + +type Context = { + args: string[]; + credentials: ClaudeCredentials; +}; + +const envFlagValues = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-e"); +const volumeMounts = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-v"); + +describe("buildClaudeDockerArgs", () => { + test("runs as host user with an OAuth token forwarded by name only", { + given: { + oauth_token_credentials, + }, + when: { + building_docker_args, + }, + then: { + uses_host_uid_gid, + sets_container_home, + mounts_workspace, + forwards_oauth_token_env_by_name, + secret_is_not_on_argv, + does_not_mount_credentials_file, + invokes_claude_headless_with_json_output, + }, + }); + + test("forwards an API key by name only", { + given: { + api_key_credentials, + }, + when: { + building_docker_args, + }, + then: { + forwards_api_key_env_by_name, + }, + }); + + test("mounts a credentials file read-only", { + given: { + credentials_file_credentials, + }, + when: { + building_docker_args, + }, + then: { + mounts_credentials_file_read_only, + does_not_mount_dot_claude_directory, + forwards_no_secret_env, + }, + }); + + test("includes --model when a model is provided", { + given: { + oauth_token_credentials, + }, + when: { + building_docker_args_with_model, + }, + then: { + includes_model_flag, + }, + }); +}); + +function oauth_token_credentials(this: Context) { + this.credentials = { kind: "oauth-token", token: SECRET }; +} + +function api_key_credentials(this: Context) { + this.credentials = { kind: "api-key", apiKey: SECRET }; +} + +function credentials_file_credentials(this: Context) { + this.credentials = { kind: "credentials-file", file: "/home/dev/.claude/.credentials.json" }; +} + +function building_docker_args(this: Context) { + this.args = buildClaudeDockerArgs({ + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "Create a README", + image: "agent-gwt/claude-code:local", + credentials: this.credentials, + uid: 1000, + gid: 1000, + }); +} + +function building_docker_args_with_model(this: Context) { + this.args = buildClaudeDockerArgs({ + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "Create a README", + image: "agent-gwt/claude-code:local", + credentials: this.credentials, + uid: 1000, + gid: 1000, + model: "sonnet", + }); +} + +function uses_host_uid_gid(this: Context) { + expect(this.args).toContain("--user"); + expect(this.args[this.args.indexOf("--user") + 1]).toBe("1000:1000"); +} + +function sets_container_home(this: Context) { + expect(envFlagValues(this.args)).toContain(`HOME=${CONTAINER_HOME}`); +} + +function mounts_workspace(this: Context) { + expect(this.args).toContain(`/tmp/.agents-gwt/ws-abc:${CONTAINER_WORKSPACE}`); +} + +function forwards_oauth_token_env_by_name(this: Context) { + const env = envFlagValues(this.args); + expect(env).toContain(CLAUDE_OAUTH_TOKEN_ENV); + expect(env).not.toContain(CLAUDE_API_KEY_ENV); +} + +function forwards_api_key_env_by_name(this: Context) { + const env = envFlagValues(this.args); + expect(env).toContain(CLAUDE_API_KEY_ENV); + expect(env).not.toContain(CLAUDE_OAUTH_TOKEN_ENV); +} + +function forwards_no_secret_env(this: Context) { + const env = envFlagValues(this.args); + expect(env).not.toContain(CLAUDE_OAUTH_TOKEN_ENV); + expect(env).not.toContain(CLAUDE_API_KEY_ENV); +} + +function secret_is_not_on_argv(this: Context) { + for (const arg of this.args) { + expect(arg.includes(SECRET)).toBe(false); + } +} + +function does_not_mount_credentials_file(this: Context) { + for (const mount of volumeMounts(this.args)) { + expect(mount.includes(CLAUDE_CONTAINER_CREDENTIALS_PATH)).toBe(false); + } +} + +function mounts_credentials_file_read_only(this: Context) { + expect(this.args).toContain( + `/home/dev/.claude/.credentials.json:${CLAUDE_CONTAINER_CREDENTIALS_PATH}:ro`, + ); +} + +function does_not_mount_dot_claude_directory(this: Context) { + for (const mount of volumeMounts(this.args)) { + expect(mount.includes("/.claude:")).toBe(false); + } +} + +function invokes_claude_headless_with_json_output(this: Context) { + expect(this.args).toContain("claude"); + expect(this.args).toContain("-p"); + expect(this.args).toContain("--output-format"); + expect(this.args).toContain("json"); + expect(this.args).toContain("--dangerously-skip-permissions"); + expect(this.args.at(-2)).toBe("--"); + expect(this.args.at(-1)).toBe("Create a README"); +} + +function includes_model_flag(this: Context) { + const modelIndex = this.args.indexOf("--model"); + expect(modelIndex).toBeGreaterThan(-1); + expect(this.args[modelIndex + 1]).toBe("sonnet"); + expect(this.args.indexOf("--")).toBeGreaterThan(modelIndex); +} diff --git a/src/agents/claude/_buildDockerArgs.ts b/src/agents/claude/_buildDockerArgs.ts new file mode 100644 index 0000000..bf21dac --- /dev/null +++ b/src/agents/claude/_buildDockerArgs.ts @@ -0,0 +1,48 @@ +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { buildDockerRunArgs } from "../docker.js"; +import type { DockerVolumeMount } from "../types.js"; +import { credentialsEnv } from "./_credentialsEnv.js"; +import type { ClaudeCredentials } from "./_resolveCredentials.js"; +import { CLAUDE_CONTAINER_CREDENTIALS_PATH } from "./constants.js"; + +export function buildClaudeDockerArgs(options: { + workspace: string; + prompt: string; + image: string; + credentials: ClaudeCredentials; + uid: number; + gid: number; + model?: string; +}): string[] { + const claudeArgs = ["claude", "-p", "--output-format", "json", "--dangerously-skip-permissions"]; + + if (options.model !== undefined && options.model !== "") { + claudeArgs.push("--model", options.model); + } + + claudeArgs.push("--", options.prompt); + + const volumes: DockerVolumeMount[] = [ + { host: options.workspace, container: CONTAINER_WORKSPACE }, + ]; + + if (options.credentials.kind === "credentials-file") { + volumes.push({ + host: options.credentials.file, + container: CLAUDE_CONTAINER_CREDENTIALS_PATH, + mode: "ro", + }); + } + + return buildDockerRunArgs({ + image: options.image, + uid: options.uid, + gid: options.gid, + workdir: CONTAINER_WORKSPACE, + env: { HOME: CONTAINER_HOME }, + // Names only; the values reach the container through the docker CLI's own environment. + envPassthrough: Object.keys(credentialsEnv(options.credentials)), + volumes, + command: claudeArgs, + }); +} diff --git a/src/agents/claude/_credentialsEnv.ts b/src/agents/claude/_credentialsEnv.ts new file mode 100644 index 0000000..93b5711 --- /dev/null +++ b/src/agents/claude/_credentialsEnv.ts @@ -0,0 +1,14 @@ +import type { ClaudeCredentials } from "./_resolveCredentials.js"; +import { CLAUDE_API_KEY_ENV, CLAUDE_OAUTH_TOKEN_ENV } from "./constants.js"; + +/** Secret values for the docker CLI process, keyed by the env names `buildClaudeDockerArgs` forwards. */ +export function credentialsEnv(credentials: ClaudeCredentials): Record { + switch (credentials.kind) { + case "oauth-token": + return { [CLAUDE_OAUTH_TOKEN_ENV]: credentials.token }; + case "api-key": + return { [CLAUDE_API_KEY_ENV]: credentials.apiKey }; + case "credentials-file": + return {}; + } +} diff --git a/src/agents/claude/_resolveCredentials.spec.ts b/src/agents/claude/_resolveCredentials.spec.ts new file mode 100644 index 0000000..9357de0 --- /dev/null +++ b/src/agents/claude/_resolveCredentials.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect } from "vitest"; +import test, { withAspect } from "vitest-gwt"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type ClaudeCredentials, resolveClaudeCredentials } from "./_resolveCredentials.js"; +import { CLAUDE_API_KEY_ENV, CLAUDE_OAUTH_TOKEN_ENV } from "./constants.js"; + +const SECRET = "sk-ant-oat01-super-secret"; + +type Context = { + home: string; + hostEnv: NodeJS.ProcessEnv; + resolved: ClaudeCredentials; +}; + +describe("resolveClaudeCredentials", () => { + withAspect(a_temp_home, remove_temp_home); + + test("prefers an OAuth token over an API key and a credentials file", { + given: { + a_credentials_file_in_home, + host_env_with_token_and_api_key, + }, + when: { + resolving_credentials, + }, + then: { + resolves_oauth_token, + }, + }); + + test("falls back to an API key", { + given: { + a_credentials_file_in_home, + host_env_with_api_key, + }, + when: { + resolving_credentials, + }, + then: { + resolves_api_key, + }, + }); + + test("falls back to a readable credentials file", { + given: { + a_credentials_file_in_home, + empty_host_env, + }, + when: { + resolving_credentials, + }, + then: { + resolves_credentials_file, + }, + }); + + test("throws with guidance when nothing is configured", { + given: { + empty_host_env, + }, + when: { + resolving_credentials, + }, + then: { + expect_error: error_explains_how_to_authenticate, + }, + }); +}); + +async function a_temp_home(this: Context) { + this.home = await mkdtemp(join(tmpdir(), "agent-gwt-home-")); +} + +async function remove_temp_home(this: Context) { + if (this.home === undefined || this.home === "") { + return; + } + + await rm(this.home, { recursive: true, force: true }); +} + +async function a_credentials_file_in_home(this: Context) { + await mkdir(join(this.home, ".claude"), { recursive: true }); + await writeFile(join(this.home, ".claude", ".credentials.json"), "{}\n"); +} + +function host_env_with_token_and_api_key(this: Context) { + this.hostEnv = { [CLAUDE_OAUTH_TOKEN_ENV]: SECRET, [CLAUDE_API_KEY_ENV]: "sk-ant-api" }; +} + +function host_env_with_api_key(this: Context) { + this.hostEnv = { [CLAUDE_API_KEY_ENV]: "sk-ant-api" }; +} + +function empty_host_env(this: Context) { + this.hostEnv = {}; +} + +async function resolving_credentials(this: Context) { + this.resolved = await resolveClaudeCredentials({ env: this.hostEnv, home: this.home }); +} + +function resolves_oauth_token(this: Context) { + expect(this.resolved).toEqual({ kind: "oauth-token", token: SECRET }); +} + +function resolves_api_key(this: Context) { + expect(this.resolved).toEqual({ kind: "api-key", apiKey: "sk-ant-api" }); +} + +function resolves_credentials_file(this: Context) { + expect(this.resolved).toEqual({ + kind: "credentials-file", + file: join(this.home, ".claude", ".credentials.json"), + }); +} + +function error_explains_how_to_authenticate(this: Context, error: Error) { + expect(error.message).toContain("claude setup-token"); + expect(error.message).toContain(CLAUDE_OAUTH_TOKEN_ENV); + expect(error.message).toContain(CLAUDE_API_KEY_ENV); +} diff --git a/src/agents/claude/_resolveCredentials.ts b/src/agents/claude/_resolveCredentials.ts new file mode 100644 index 0000000..e65427e --- /dev/null +++ b/src/agents/claude/_resolveCredentials.ts @@ -0,0 +1,49 @@ +import { access } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { homedir } from "node:os"; + +import { + CLAUDE_API_KEY_ENV, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, +} from "./constants.js"; + +/** + * How Claude Code authenticates inside the container. Env-backed kinds are forwarded + * to `docker run` by name (never on argv); the file kind is bind-mounted read-only. + * See `constants.ts` for which host setups produce each. + */ +export type ClaudeCredentials = + | { kind: "oauth-token"; token: string } + | { kind: "api-key"; apiKey: string } + | { kind: "credentials-file"; file: string }; + +/** Env OAuth token, then env API key, then a readable host credentials file. */ +export async function resolveClaudeCredentials( + options: { env?: NodeJS.ProcessEnv; home?: string } = {}, +): Promise { + const env = options.env ?? process.env; + const home = options.home ?? homedir(); + + const token = env[CLAUDE_OAUTH_TOKEN_ENV]; + if (token !== undefined && token !== "") { + return { kind: "oauth-token", token }; + } + + const apiKey = env[CLAUDE_API_KEY_ENV]; + if (apiKey !== undefined && apiKey !== "") { + return { kind: "api-key", apiKey }; + } + + const file = defaultClaudeHostCredentialsFile(home); + try { + await access(file, fsConstants.R_OK); + } catch { + throw new Error( + `Claude Code credentials not found. Set ${CLAUDE_OAUTH_TOKEN_ENV} (run \`claude setup-token\` on the host) ` + + `or ${CLAUDE_API_KEY_ENV}, or provide ${file} (Linux hosts; macOS keeps credentials in the Keychain).`, + ); + } + + return { kind: "credentials-file", file }; +} diff --git a/src/agents/claude/index.ts b/src/agents/claude/index.ts index 1a087ef..d116e21 100644 --- a/src/agents/claude/index.ts +++ b/src/agents/claude/index.ts @@ -6,12 +6,7 @@ export { CLAUDE_OAUTH_TOKEN_ENV, defaultClaudeHostCredentialsFile, } from "./constants.js"; -export { - buildClaudeDockerArgs, - resolveClaudeCredentials, - runClaudeInDocker, - type ClaudeAgentResult, - type ClaudeCredentials, - type RunClaudeInDockerOptions, -} from "./run.js"; +export { buildClaudeDockerArgs } from "./_buildDockerArgs.js"; +export { resolveClaudeCredentials, type ClaudeCredentials } from "./_resolveCredentials.js"; +export { runClaudeInDocker, type ClaudeAgentResult, type RunClaudeInDockerOptions } from "./run.js"; export { claudeAgent } from "./agent.js"; diff --git a/src/agents/claude/run.spec.ts b/src/agents/claude/run.spec.ts index c8f0cab..8add4b5 100644 --- a/src/agents/claude/run.spec.ts +++ b/src/agents/claude/run.spec.ts @@ -1,314 +1,21 @@ -import { afterEach, describe, expect } from "vitest"; +import { describe, expect } from "vitest"; import test from "vitest-gwt"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { - CLAUDE_API_KEY_ENV, - CLAUDE_CONTAINER_CREDENTIALS_PATH, - CLAUDE_OAUTH_TOKEN_ENV, -} from "./constants.js"; -import { - buildClaudeDockerArgs, - resolveClaudeCredentials, - runClaudeInDocker, - type ClaudeCredentials, -} from "./run.js"; +import type { ClaudeCredentials } from "./_resolveCredentials.js"; +import { CLAUDE_API_KEY_ENV, CLAUDE_OAUTH_TOKEN_ENV } from "./constants.js"; +import { runClaudeInDocker } from "./run.js"; import type { DockerRunOptions, DockerRunner } from "../types.js"; const SECRET = "sk-ant-oat01-super-secret"; type Context = { - args: string[]; result: unknown; credentials: ClaudeCredentials; - resolved: ClaudeCredentials; dockerRunner: DockerRunner; lastArgs: string[]; lastRunOptions: DockerRunOptions | undefined; - hostEnv: NodeJS.ProcessEnv; - home: string; }; -const envFlagValues = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-e"); -const volumeMounts = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-v"); - -const tempRoots: string[] = []; - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); -}); - -describe("buildClaudeDockerArgs", () => { - test("runs as host user with an OAuth token forwarded by name only", { - given: { - oauth_token_credentials, - }, - when: { - building_docker_args, - }, - then: { - uses_host_uid_gid, - sets_container_home, - mounts_workspace, - forwards_oauth_token_env_by_name, - secret_is_not_on_argv, - does_not_mount_credentials_file, - invokes_claude_headless_with_json_output, - }, - }); - - test("forwards an API key by name only", { - given: { - api_key_credentials, - }, - when: { - building_docker_args, - }, - then: { - forwards_api_key_env_by_name, - }, - }); - - test("mounts a credentials file read-only", { - given: { - credentials_file_credentials, - }, - when: { - building_docker_args, - }, - then: { - mounts_credentials_file_read_only, - does_not_mount_dot_claude_directory, - forwards_no_secret_env, - }, - }); - - test("includes --model when a model is provided", { - given: { - oauth_token_credentials, - }, - when: { - building_docker_args_with_model, - }, - then: { - includes_model_flag, - }, - }); -}); - -function oauth_token_credentials(this: Context) { - this.credentials = { kind: "oauth-token", token: SECRET }; -} - -function api_key_credentials(this: Context) { - this.credentials = { kind: "api-key", apiKey: SECRET }; -} - -function credentials_file_credentials(this: Context) { - this.credentials = { kind: "credentials-file", file: "/home/dev/.claude/.credentials.json" }; -} - -function building_docker_args(this: Context) { - this.args = buildClaudeDockerArgs({ - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "Create a README", - image: "agent-gwt/claude-code:local", - credentials: this.credentials, - uid: 1000, - gid: 1000, - }); -} - -function building_docker_args_with_model(this: Context) { - this.args = buildClaudeDockerArgs({ - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "Create a README", - image: "agent-gwt/claude-code:local", - credentials: this.credentials, - uid: 1000, - gid: 1000, - model: "sonnet", - }); -} - -function uses_host_uid_gid(this: Context) { - expect(this.args).toContain("--user"); - expect(this.args[this.args.indexOf("--user") + 1]).toBe("1000:1000"); -} - -function sets_container_home(this: Context) { - expect(envFlagValues(this.args)).toContain(`HOME=${CONTAINER_HOME}`); -} - -function mounts_workspace(this: Context) { - expect(this.args).toContain(`/tmp/.agents-gwt/ws-abc:${CONTAINER_WORKSPACE}`); -} - -function forwards_oauth_token_env_by_name(this: Context) { - const env = envFlagValues(this.args); - expect(env).toContain(CLAUDE_OAUTH_TOKEN_ENV); - expect(env).not.toContain(CLAUDE_API_KEY_ENV); -} - -function forwards_api_key_env_by_name(this: Context) { - const env = envFlagValues(this.args); - expect(env).toContain(CLAUDE_API_KEY_ENV); - expect(env).not.toContain(CLAUDE_OAUTH_TOKEN_ENV); -} - -function forwards_no_secret_env(this: Context) { - const env = envFlagValues(this.args); - expect(env).not.toContain(CLAUDE_OAUTH_TOKEN_ENV); - expect(env).not.toContain(CLAUDE_API_KEY_ENV); -} - -function secret_is_not_on_argv(this: Context) { - for (const arg of this.args) { - expect(arg.includes(SECRET)).toBe(false); - } -} - -function does_not_mount_credentials_file(this: Context) { - for (const mount of volumeMounts(this.args)) { - expect(mount.includes(CLAUDE_CONTAINER_CREDENTIALS_PATH)).toBe(false); - } -} - -function mounts_credentials_file_read_only(this: Context) { - expect(this.args).toContain( - `/home/dev/.claude/.credentials.json:${CLAUDE_CONTAINER_CREDENTIALS_PATH}:ro`, - ); -} - -function does_not_mount_dot_claude_directory(this: Context) { - for (const mount of volumeMounts(this.args)) { - expect(mount.includes("/.claude:")).toBe(false); - } -} - -function invokes_claude_headless_with_json_output(this: Context) { - expect(this.args).toContain("claude"); - expect(this.args).toContain("-p"); - expect(this.args).toContain("--output-format"); - expect(this.args).toContain("json"); - expect(this.args).toContain("--dangerously-skip-permissions"); - expect(this.args.at(-2)).toBe("--"); - expect(this.args.at(-1)).toBe("Create a README"); -} - -function includes_model_flag(this: Context) { - const modelIndex = this.args.indexOf("--model"); - expect(modelIndex).toBeGreaterThan(-1); - expect(this.args[modelIndex + 1]).toBe("sonnet"); - expect(this.args.indexOf("--")).toBeGreaterThan(modelIndex); -} - -describe("resolveClaudeCredentials", () => { - test("prefers an OAuth token over an API key and a credentials file", { - given: { - home_with_credentials_file, - host_env_with_token_and_api_key, - }, - when: { - resolving_credentials, - }, - then: { - resolves_oauth_token, - }, - }); - - test("falls back to an API key", { - given: { - home_with_credentials_file, - host_env_with_api_key, - }, - when: { - resolving_credentials, - }, - then: { - resolves_api_key, - }, - }); - - test("falls back to a readable credentials file", { - given: { - home_with_credentials_file, - empty_host_env, - }, - when: { - resolving_credentials, - }, - then: { - resolves_credentials_file, - }, - }); - - test("throws with guidance when nothing is configured", { - given: { - home_without_credentials_file, - empty_host_env, - }, - when: { - resolving_credentials, - }, - then: { - expect_error: error_explains_how_to_authenticate, - }, - }); -}); - -async function home_with_credentials_file(this: Context) { - this.home = await mkdtemp(join(tmpdir(), "agent-gwt-home-")); - tempRoots.push(this.home); - await mkdir(join(this.home, ".claude"), { recursive: true }); - await writeFile(join(this.home, ".claude", ".credentials.json"), "{}\n"); -} - -async function home_without_credentials_file(this: Context) { - this.home = await mkdtemp(join(tmpdir(), "agent-gwt-home-")); - tempRoots.push(this.home); -} - -function host_env_with_token_and_api_key(this: Context) { - this.hostEnv = { [CLAUDE_OAUTH_TOKEN_ENV]: SECRET, [CLAUDE_API_KEY_ENV]: "sk-ant-api" }; -} - -function host_env_with_api_key(this: Context) { - this.hostEnv = { [CLAUDE_API_KEY_ENV]: "sk-ant-api" }; -} - -function empty_host_env(this: Context) { - this.hostEnv = {}; -} - -async function resolving_credentials(this: Context) { - this.resolved = await resolveClaudeCredentials({ env: this.hostEnv, home: this.home }); -} - -function resolves_oauth_token(this: Context) { - expect(this.resolved).toEqual({ kind: "oauth-token", token: SECRET }); -} - -function resolves_api_key(this: Context) { - expect(this.resolved).toEqual({ kind: "api-key", apiKey: "sk-ant-api" }); -} - -function resolves_credentials_file(this: Context) { - expect(this.resolved).toEqual({ - kind: "credentials-file", - file: join(this.home, ".claude", ".credentials.json"), - }); -} - -function error_explains_how_to_authenticate(this: Context, error: Error) { - expect(error.message).toContain("claude setup-token"); - expect(error.message).toContain(CLAUDE_OAUTH_TOKEN_ENV); - expect(error.message).toContain(CLAUDE_API_KEY_ENV); -} - describe("runClaudeInDocker", () => { test("parses JSON from a successful run and hands the OAuth token to the docker CLI env", { given: { @@ -416,6 +123,14 @@ function error_result_docker_runner(this: Context) { }); } +function oauth_token_credentials(this: Context) { + this.credentials = { kind: "oauth-token", token: SECRET }; +} + +function api_key_credentials(this: Context) { + this.credentials = { kind: "api-key", apiKey: SECRET }; +} + async function running_claude_in_docker(this: Context) { this.result = await runClaudeInDocker( { diff --git a/src/agents/claude/run.ts b/src/agents/claude/run.ts index 41e37d4..6083178 100644 --- a/src/agents/claude/run.ts +++ b/src/agents/claude/run.ts @@ -1,28 +1,10 @@ -import { access } from "node:fs/promises"; -import { constants as fsConstants } from "node:fs"; -import { homedir } from "node:os"; - -import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { buildDockerRunArgs, runDocker } from "../docker.js"; +import { runDocker } from "../docker.js"; import { parseAgentJsonOutput } from "../parse-result.js"; import { agentRunError } from "../run-error.js"; -import type { AgentRunBindingsOptions, DockerRunner, DockerVolumeMount } from "../types.js"; -import { - CLAUDE_API_KEY_ENV, - CLAUDE_CONTAINER_CREDENTIALS_PATH, - CLAUDE_OAUTH_TOKEN_ENV, - defaultClaudeHostCredentialsFile, -} from "./constants.js"; - -/** - * How Claude Code authenticates inside the container. Env-backed kinds are forwarded - * to `docker run` by name (never on argv); the file kind is bind-mounted read-only. - * See `constants.ts` for which host setups produce each. - */ -export type ClaudeCredentials = - | { kind: "oauth-token"; token: string } - | { kind: "api-key"; apiKey: string } - | { kind: "credentials-file"; file: string }; +import type { AgentRunBindingsOptions, DockerRunner } from "../types.js"; +import { buildClaudeDockerArgs } from "./_buildDockerArgs.js"; +import { credentialsEnv } from "./_credentialsEnv.js"; +import { type ClaudeCredentials, resolveClaudeCredentials } from "./_resolveCredentials.js"; export type RunClaudeInDockerOptions = AgentRunBindingsOptions & { /** Defaults to `resolveClaudeCredentials()`. */ @@ -47,78 +29,6 @@ export type ClaudeAgentResult = { usage?: Record; }; -/** Env OAuth token, then env API key, then a readable host credentials file. */ -export async function resolveClaudeCredentials( - options: { env?: NodeJS.ProcessEnv; home?: string } = {}, -): Promise { - const env = options.env ?? process.env; - const home = options.home ?? homedir(); - - const token = env[CLAUDE_OAUTH_TOKEN_ENV]; - if (token !== undefined && token !== "") { - return { kind: "oauth-token", token }; - } - - const apiKey = env[CLAUDE_API_KEY_ENV]; - if (apiKey !== undefined && apiKey !== "") { - return { kind: "api-key", apiKey }; - } - - const file = defaultClaudeHostCredentialsFile(home); - try { - await access(file, fsConstants.R_OK); - } catch { - throw new Error( - `Claude Code credentials not found. Set ${CLAUDE_OAUTH_TOKEN_ENV} (run \`claude setup-token\` on the host) ` + - `or ${CLAUDE_API_KEY_ENV}, or provide ${file} (Linux hosts; macOS keeps credentials in the Keychain).`, - ); - } - - return { kind: "credentials-file", file }; -} - -export function buildClaudeDockerArgs(options: { - workspace: string; - prompt: string; - image: string; - credentials: ClaudeCredentials; - uid: number; - gid: number; - model?: string; -}): string[] { - const claudeArgs = ["claude", "-p", "--output-format", "json", "--dangerously-skip-permissions"]; - - if (options.model !== undefined && options.model !== "") { - claudeArgs.push("--model", options.model); - } - - claudeArgs.push("--", options.prompt); - - const volumes: DockerVolumeMount[] = [ - { host: options.workspace, container: CONTAINER_WORKSPACE }, - ]; - - if (options.credentials.kind === "credentials-file") { - volumes.push({ - host: options.credentials.file, - container: CLAUDE_CONTAINER_CREDENTIALS_PATH, - mode: "ro", - }); - } - - return buildDockerRunArgs({ - image: options.image, - uid: options.uid, - gid: options.gid, - workdir: CONTAINER_WORKSPACE, - env: { HOME: CONTAINER_HOME }, - // Names only; the values reach the container through the docker CLI's own environment. - envPassthrough: Object.keys(credentialsEnv(options.credentials)), - volumes, - command: claudeArgs, - }); -} - export async function runClaudeInDocker( options: RunClaudeInDockerOptions, dockerRunner: DockerRunner = runDocker, @@ -158,18 +68,6 @@ export async function runClaudeInDocker( return parsed; } -/** Secret values for the docker CLI process, keyed by the env names `buildClaudeDockerArgs` forwards. */ -function credentialsEnv(credentials: ClaudeCredentials): Record { - switch (credentials.kind) { - case "oauth-token": - return { [CLAUDE_OAUTH_TOKEN_ENV]: credentials.token }; - case "api-key": - return { [CLAUDE_API_KEY_ENV]: credentials.apiKey }; - case "credentials-file": - return {}; - } -} - function isErrorResult(value: unknown): value is ClaudeAgentResult { return ( typeof value === "object" && diff --git a/src/agents/cursor/_buildDockerArgs.spec.ts b/src/agents/cursor/_buildDockerArgs.spec.ts new file mode 100644 index 0000000..011378c --- /dev/null +++ b/src/agents/cursor/_buildDockerArgs.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { buildDockerArgs } from "./_buildDockerArgs.js"; +import { CONTAINER_AUTH_PATH } from "./constants.js"; + +type Context = { + args: string[]; +}; + +describe("buildDockerArgs", () => { + test("runs as host user with credentials-only and workspace mounts", { + when: { + building_docker_args, + }, + then: { + uses_host_uid_gid, + sets_container_home, + mounts_workspace, + mounts_auth_file_read_only, + does_not_mount_dot_cursor_directory, + invokes_agent_with_json_output, + }, + }); + + test("includes --model when a model is provided", { + when: { + building_docker_args_with_model, + }, + then: { + includes_model_flag, + }, + }); +}); + +function building_docker_args(this: Context) { + this.args = buildDockerArgs({ + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "Create a README", + image: "agent-gwt/cursor-cli:local", + authFile: "/home/dev/.config/cursor/auth.json", + uid: 1000, + gid: 1000, + }); +} + +function building_docker_args_with_model(this: Context) { + this.args = buildDockerArgs({ + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "Create a README", + image: "agent-gwt/cursor-cli:local", + authFile: "/home/dev/.config/cursor/auth.json", + uid: 1000, + gid: 1000, + model: "composer-2", + }); +} + +function uses_host_uid_gid(this: Context) { + expect(this.args).toContain("--user"); + expect(this.args[this.args.indexOf("--user") + 1]).toBe("1000:1000"); +} + +function sets_container_home(this: Context) { + const homeFlagIndex = this.args.findIndex( + (arg, i) => arg === "-e" && this.args[i + 1]?.startsWith("HOME="), + ); + expect(this.args[homeFlagIndex + 1]).toBe(`HOME=${CONTAINER_HOME}`); +} + +function mounts_workspace(this: Context) { + expect(this.args).toContain(`/tmp/.agents-gwt/ws-abc:${CONTAINER_WORKSPACE}`); +} + +function mounts_auth_file_read_only(this: Context) { + expect(this.args).toContain(`/home/dev/.config/cursor/auth.json:${CONTAINER_AUTH_PATH}:ro`); +} + +function does_not_mount_dot_cursor_directory(this: Context) { + const volumeMounts = this.args.filter((arg, i) => this.args[i - 1] === "-v"); + for (const mount of volumeMounts) { + expect(mount.includes("/.cursor:")).toBe(false); + } +} + +function invokes_agent_with_json_output(this: Context) { + expect(this.args).toContain("agent"); + expect(this.args).toContain("--output-format"); + expect(this.args).toContain("json"); + expect(this.args).toContain("--force"); + expect(this.args.at(-1)).toBe("Create a README"); +} + +function includes_model_flag(this: Context) { + const modelIndex = this.args.indexOf("--model"); + expect(modelIndex).toBeGreaterThan(-1); + expect(this.args[modelIndex + 1]).toBe("composer-2"); + expect(this.args.indexOf("--")).toBeGreaterThan(modelIndex); +} diff --git a/src/agents/cursor/_buildDockerArgs.ts b/src/agents/cursor/_buildDockerArgs.ts new file mode 100644 index 0000000..cb752c3 --- /dev/null +++ b/src/agents/cursor/_buildDockerArgs.ts @@ -0,0 +1,34 @@ +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { buildDockerRunArgs } from "../docker.js"; +import { CONTAINER_AUTH_PATH } from "./constants.js"; + +export function buildDockerArgs(options: { + workspace: string; + prompt: string; + image: string; + authFile: string; + uid: number; + gid: number; + model?: string; +}): string[] { + const agentArgs = ["agent", "-p", "--force", "--output-format", "json"]; + + if (options.model !== undefined && options.model !== "") { + agentArgs.push("--model", options.model); + } + + agentArgs.push("--", options.prompt); + + return buildDockerRunArgs({ + image: options.image, + uid: options.uid, + gid: options.gid, + workdir: CONTAINER_WORKSPACE, + env: { HOME: CONTAINER_HOME }, + volumes: [ + { host: options.workspace, container: CONTAINER_WORKSPACE }, + { host: options.authFile, container: CONTAINER_AUTH_PATH, mode: "ro" }, + ], + command: agentArgs, + }); +} diff --git a/src/agents/cursor/index.ts b/src/agents/cursor/index.ts index c6fb9fb..181f12f 100644 --- a/src/agents/cursor/index.ts +++ b/src/agents/cursor/index.ts @@ -4,5 +4,6 @@ export { CURSOR_IMAGE, defaultHostAuthFile, } from "./constants.js"; -export { buildDockerArgs, runCursorInDocker, type RunCursorInDockerOptions } from "./run.js"; +export { buildDockerArgs } from "./_buildDockerArgs.js"; +export { runCursorInDocker, type RunCursorInDockerOptions } from "./run.js"; export { cursorAgent } from "./agent.js"; diff --git a/src/agents/cursor/run.spec.ts b/src/agents/cursor/run.spec.ts index 334737e..4929da1 100644 --- a/src/agents/cursor/run.spec.ts +++ b/src/agents/cursor/run.spec.ts @@ -2,108 +2,15 @@ import { describe, expect } from "vitest"; import test from "vitest-gwt"; import { join } from "node:path"; -import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { CONTAINER_AUTH_PATH } from "./constants.js"; -import { buildDockerArgs, runCursorInDocker } from "./run.js"; +import { runCursorInDocker } from "./run.js"; import type { DockerRunner } from "../types.js"; type Context = { - args: string[]; result: unknown; dockerRunner: DockerRunner; authFile: string; }; -describe("buildDockerArgs", () => { - test("runs as host user with credentials-only and workspace mounts", { - when: { - building_docker_args, - }, - then: { - uses_host_uid_gid, - sets_container_home, - mounts_workspace, - mounts_auth_file_read_only, - does_not_mount_dot_cursor_directory, - invokes_agent_with_json_output, - }, - }); - - test("includes --model when a model is provided", { - when: { - building_docker_args_with_model, - }, - then: { - includes_model_flag, - }, - }); -}); - -function building_docker_args(this: Context) { - this.args = buildDockerArgs({ - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "Create a README", - image: "agent-gwt/cursor-cli:local", - authFile: "/home/dev/.config/cursor/auth.json", - uid: 1000, - gid: 1000, - }); -} - -function building_docker_args_with_model(this: Context) { - this.args = buildDockerArgs({ - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "Create a README", - image: "agent-gwt/cursor-cli:local", - authFile: "/home/dev/.config/cursor/auth.json", - uid: 1000, - gid: 1000, - model: "composer-2", - }); -} - -function uses_host_uid_gid(this: Context) { - expect(this.args).toContain("--user"); - expect(this.args[this.args.indexOf("--user") + 1]).toBe("1000:1000"); -} - -function sets_container_home(this: Context) { - const homeFlagIndex = this.args.findIndex( - (arg, i) => arg === "-e" && this.args[i + 1]?.startsWith("HOME="), - ); - expect(this.args[homeFlagIndex + 1]).toBe(`HOME=${CONTAINER_HOME}`); -} - -function mounts_workspace(this: Context) { - expect(this.args).toContain(`/tmp/.agents-gwt/ws-abc:${CONTAINER_WORKSPACE}`); -} - -function mounts_auth_file_read_only(this: Context) { - expect(this.args).toContain(`/home/dev/.config/cursor/auth.json:${CONTAINER_AUTH_PATH}:ro`); -} - -function does_not_mount_dot_cursor_directory(this: Context) { - const volumeMounts = this.args.filter((arg, i) => this.args[i - 1] === "-v"); - for (const mount of volumeMounts) { - expect(mount.includes("/.cursor:")).toBe(false); - } -} - -function invokes_agent_with_json_output(this: Context) { - expect(this.args).toContain("agent"); - expect(this.args).toContain("--output-format"); - expect(this.args).toContain("json"); - expect(this.args).toContain("--force"); - expect(this.args.at(-1)).toBe("Create a README"); -} - -function includes_model_flag(this: Context) { - const modelIndex = this.args.indexOf("--model"); - expect(modelIndex).toBeGreaterThan(-1); - expect(this.args[modelIndex + 1]).toBe("composer-2"); - expect(this.args.indexOf("--")).toBeGreaterThan(modelIndex); -} - describe("runCursorInDocker", () => { test("parses JSON from a successful docker run", { given: { diff --git a/src/agents/cursor/run.ts b/src/agents/cursor/run.ts index 862f01d..2a6425e 100644 --- a/src/agents/cursor/run.ts +++ b/src/agents/cursor/run.ts @@ -2,12 +2,12 @@ import { access } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { homedir } from "node:os"; -import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { buildDockerRunArgs, runDocker } from "../docker.js"; +import { runDocker } from "../docker.js"; import { parseAgentJsonOutput } from "../parse-result.js"; import { agentRunError } from "../run-error.js"; import type { DockerRunner, AgentRunBindingsOptions } from "../types.js"; -import { CONTAINER_AUTH_PATH, defaultHostAuthFile } from "./constants.js"; +import { buildDockerArgs } from "./_buildDockerArgs.js"; +import { defaultHostAuthFile } from "./constants.js"; export type RunCursorInDockerOptions = AgentRunBindingsOptions & { authFile?: string; @@ -15,37 +15,6 @@ export type RunCursorInDockerOptions = AgentRunBindingsOptions & { gid?: number; }; -export function buildDockerArgs(options: { - workspace: string; - prompt: string; - image: string; - authFile: string; - uid: number; - gid: number; - model?: string; -}): string[] { - const agentArgs = ["agent", "-p", "--force", "--output-format", "json"]; - - if (options.model !== undefined && options.model !== "") { - agentArgs.push("--model", options.model); - } - - agentArgs.push("--", options.prompt); - - return buildDockerRunArgs({ - image: options.image, - uid: options.uid, - gid: options.gid, - workdir: CONTAINER_WORKSPACE, - env: { HOME: CONTAINER_HOME }, - volumes: [ - { host: options.workspace, container: CONTAINER_WORKSPACE }, - { host: options.authFile, container: CONTAINER_AUTH_PATH, mode: "ro" }, - ], - command: agentArgs, - }); -} - export async function runCursorInDocker( options: RunCursorInDockerOptions, dockerRunner: DockerRunner = runDocker,