Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/agents/claude/_buildDockerArgs.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions src/agents/claude/_buildDockerArgs.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
14 changes: 14 additions & 0 deletions src/agents/claude/_credentialsEnv.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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 {};
}
}
125 changes: 125 additions & 0 deletions src/agents/claude/_resolveCredentials.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading