diff --git a/README.md b/README.md index 821563a..987dd78 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,10 @@ openlock validate /path/to/your/repo # lint the manifest + policy openlock sandbox /path/to/your/repo # launch (path defaults to cwd) ``` -`sandbox` requires an `.openlock/` (run `openlock init` first, or it errors). The first -`sandbox` run prompts for `claude setup-token` if you have no credentials, runs `git init` -if the path isn't a git repo yet, and (re)attaches the session. +`sandbox` requires an `.openlock/` (run `openlock init` first, or it errors). If you have no +credentials it runs `openlock login` (the Anthropic provider imports your Claude subscription +token from an isolated `claude auth login`). The first run also `git init`s the path if it +isn't a git repo yet, and (re)attaches the session. ## Usage diff --git a/docs/agent-config-reference.md b/docs/agent-config-reference.md index 1b08e36..19bdf2e 100644 --- a/docs/agent-config-reference.md +++ b/docs/agent-config-reference.md @@ -27,7 +27,7 @@ Top-level keys: `version` (required, integer) plus optional `filesystem_policy`, - endpoint keys: `host`, `port`, `ports`, `protocol`, `tls`, `enforcement`, `access`, `rules`, `allowed_ips`, `deny_rules`, `allow_encoded_slash`, `cred_inject`, `echo`, `trust_check`. - L7 rule: `allow` with matchers `method`, `path`, `command`, `query`; `deny_rules` use the same matchers. The query matcher key is `any`. - - `cred_inject`: `provider`, `strip_headers`, `inject` (each inject entry has `header`, `from_credential`). + - `cred_inject`: `provider`, `strip_headers`, `inject` (each inject entry has `header`, `from_credential`, and an optional `value_prefix` — a literal string such as `"Bearer "` prepended to the resolved credential when composing the header). - `trust_check`: `registry`. - binary entry: `path` (string). A deprecated `harness` boolean is also accepted on a binary entry — legacy, unrelated to the top-level harness enum below; real policies omit it. - `filesystem_policy`: `include_workdir`, `read_only`, `read_write`. diff --git a/policies/default.yaml b/policies/default.yaml index 7e71700..ed4e717 100644 --- a/policies/default.yaml +++ b/policies/default.yaml @@ -47,6 +47,7 @@ network_policies: inject: - header: Authorization from_credential: ANTHROPIC_BEARER_TOKEN + value_prefix: 'Bearer ' allowed_secrets: - ANTHROPIC_BEARER_TOKEN opencode: @@ -54,23 +55,6 @@ network_policies: - path: /usr/local/bin/opencode - path: /usr/local/bin/node endpoints: - - host: api.anthropic.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: - method: POST - path: /v1/** - cred_inject: - provider: anthropic - strip_headers: - - Authorization - - x-api-key - - Cookie - inject: - - header: x-api-key - from_credential: ANTHROPIC_API_KEY - host: openrouter.ai port: 443 protocol: rest @@ -97,7 +81,6 @@ network_policies: method: GET path: /** allowed_secrets: - - ANTHROPIC_API_KEY - OPENROUTER_BEARER_TOKEN npm_packages: binaries: diff --git a/policies/wire-proof-local.yaml b/policies/wire-proof-local.yaml index 4d52403..a180095 100644 --- a/policies/wire-proof-local.yaml +++ b/policies/wire-proof-local.yaml @@ -2,7 +2,7 @@ # # Usage: # Terminal 1: just gateway-vm -# Terminal 2: just cli provider create --name anthropic --type claude --credential ANTHROPIC_API_KEY=sk-test-key +# Terminal 2: just cli provider create --name anthropic --type claude-oauth --credential ANTHROPIC_BEARER_TOKEN=sk-test-key # just cli sandbox create --name wire-proof --provider anthropic \ # --policy policies/wire-proof-local.yaml -- /bin/bash # @@ -40,5 +40,5 @@ network_policies: provider: anthropic strip_headers: [Authorization, x-api-key, Cookie] inject: - - { header: Authorization, from_credential: ANTHROPIC_BEARER_TOKEN } - allowed_secrets: [ANTHROPIC_BEARER_TOKEN, ANTHROPIC_AUTH_TOKEN] + - { header: Authorization, from_credential: ANTHROPIC_BEARER_TOKEN, value_prefix: "Bearer " } + allowed_secrets: [ANTHROPIC_BEARER_TOKEN] diff --git a/scripts/render-default-policies.ts b/scripts/render-default-policies.ts index 5e398b2..86d2e5a 100644 --- a/scripts/render-default-policies.ts +++ b/scripts/render-default-policies.ts @@ -51,6 +51,9 @@ function harnessBlock(harness: Harness): Record { inject: ep.cred_inject.inject.map((i) => ({ header: i.header, from_credential: i.from_credential, + // Preserve the literal prefix (e.g. "Bearer ") when present; a + // cred whose stored value carries no prefix omits the key. + ...(i.value_prefix ? { value_prefix: i.value_prefix } : {}), })), }, } diff --git a/src/cli/sandbox.test.ts b/src/cli/sandbox.test.ts index 6f25477..79a7b51 100644 --- a/src/cli/sandbox.test.ts +++ b/src/cli/sandbox.test.ts @@ -24,4 +24,13 @@ describe("sandbox flagSchema (extended)", () => { }); expect(values.provider).toBe("openrouter"); }); + + it("accepts --no-attach as a boolean", () => { + const { values } = parseArgs({ + args: ["--no-attach"], + options: flagSchema, + allowPositionals: true, + }); + expect(values["no-attach"]).toBe(true); + }); }); diff --git a/src/cli/sandbox.ts b/src/cli/sandbox.ts index 616ff27..68d841c 100644 --- a/src/cli/sandbox.ts +++ b/src/cli/sandbox.ts @@ -7,6 +7,7 @@ export const flagSchema = { harness: { type: "string" }, provider: { type: "string" }, branch: { type: "string", short: "b" }, + "no-attach": { type: "boolean" }, help: { type: "boolean", short: "h" }, } as const satisfies ParseArgsOptionsConfig; @@ -29,6 +30,7 @@ export function sandboxCmd(args: string[]): void { harness: values.harness, provider: values.provider, branch: values.branch, + noAttach: values["no-attach"] === true, }), ); } diff --git a/src/config-core/policy/schema.test.ts b/src/config-core/policy/schema.test.ts index 4a52282..5c72d71 100644 --- a/src/config-core/policy/schema.test.ts +++ b/src/config-core/policy/schema.test.ts @@ -157,6 +157,52 @@ describe("validateSchema", () => { expect(errors.some((e) => e.message.includes("nope"))).toBe(true); }); + test("accepts cred_inject header with value_prefix", () => { + const errors = validateSchema({ + version: 1, + network_policies: { + test: { + endpoints: [ + { + host: "api.anthropic.com", + cred_inject: { + provider: "anthropic", + strip_headers: ["Authorization"], + inject: [ + { + header: "Authorization", + from_credential: "ANTHROPIC_BEARER_TOKEN", + value_prefix: "Bearer ", + }, + ], + }, + }, + ], + }, + }, + }); + expect(errors).toHaveLength(0); + }); + + test("rejects unknown field in cred_inject header (allowlist still enforced)", () => { + const errors = validateSchema({ + version: 1, + network_policies: { + test: { + endpoints: [ + { + host: "x.com", + cred_inject: { + inject: [{ header: "Authorization", from_credential: "X", value_suffix: "nope" }], + }, + }, + ], + }, + }, + }); + expect(errors.some((e) => e.message.includes("value_suffix"))).toBe(true); + }); + test("rejects missing required fields in cred_inject header", () => { const errors = validateSchema({ version: 1, diff --git a/src/config-core/policy/schema.ts b/src/config-core/policy/schema.ts index 4ffad94..abf9940 100644 --- a/src/config-core/policy/schema.ts +++ b/src/config-core/policy/schema.ts @@ -4,7 +4,7 @@ const L7_ALLOW_KEYS = new Set(["method", "path", "command", "query"]); const L7_RULE_KEYS = new Set(["allow"]); const L7_DENY_KEYS = new Set(["method", "path", "command", "query"]); -const CRED_INJECT_HEADER_KEYS = new Set(["header", "from_credential"]); +const CRED_INJECT_HEADER_KEYS = new Set(["header", "from_credential", "value_prefix"]); const CRED_INJECT_KEYS = new Set(["provider", "strip_headers", "inject"]); const TRUST_CHECK_KEYS = new Set(["registry"]); @@ -281,6 +281,7 @@ function validateCredInjectHeader(val: unknown, path: string): ValidationError[] for (const f of ["header", "from_credential"] as const) { requireScalar(errors, obj, f, "string", path); } + optionalScalar(errors, obj, "value_prefix", "string", path); return errors; } diff --git a/src/config-core/policy/types.ts b/src/config-core/policy/types.ts index 20f7603..3a4b157 100644 --- a/src/config-core/policy/types.ts +++ b/src/config-core/policy/types.ts @@ -1,6 +1,7 @@ interface CredInjectHeader { header: string; from_credential: string; + value_prefix?: string; } interface CredInject { diff --git a/src/login.ts b/src/login.ts index 83f4b7e..0c97c39 100644 --- a/src/login.ts +++ b/src/login.ts @@ -52,11 +52,12 @@ export async function _loginForTests(args: { const id = args.providerFlag ? validateProviderId(args.providerFlag) : await args.pick(args.io); const plugin = PROVIDERS[id]; args.io.writeStdout(`\nAuthenticating with ${plugin.displayName}...\n`); - const creds = await plugin.loginInteractive(args.io); + const result = await plugin.loginInteractive(args.io); writeProvider(id, { type: plugin.openshellType, - credentials: creds, + credentials: result.credentials, created_at: new Date().toISOString(), + refresh: result.refresh, }); args.io.writeStdout(`\nCredentials saved for provider '${id}'.\n`); } diff --git a/src/providers/anthropic-import.test.ts b/src/providers/anthropic-import.test.ts new file mode 100644 index 0000000..9f0e550 --- /dev/null +++ b/src/providers/anthropic-import.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { + claudeKeychainService, + type ImportDeps, + importFromClaudeCode, + parseClaudeOauthBlob, +} from "./anthropic-import"; +import type { LoginIO } from "./types"; + +describe("parseClaudeOauthBlob", () => { + const valid = JSON.stringify({ + claudeAiOauth: { + accessToken: "sk-ant-oat01-realish", + refreshToken: "sk-ant-ort01-realish", + expiresAt: 1893456000000, // 2030-01-01, deterministic + scopes: ["user:profile", "user:inference", "user:sessions:claude_code"], + subscriptionType: "max", + }, + }); + + it("maps the claudeAiOauth blob to a LoginResult with raw access token", () => { + const r = parseClaudeOauthBlob(valid); + expect(r.credentials).toEqual({ ANTHROPIC_BEARER_TOKEN: "sk-ant-oat01-realish" }); + expect(r.refresh?.strategy).toBe("oauth2_refresh_token"); + expect(r.refresh?.token_url).toBe("https://platform.claude.com/v1/oauth/token"); + expect(r.refresh?.client_id).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e"); + expect(r.refresh?.refresh_token).toBe("sk-ant-ort01-realish"); + expect(r.refresh?.scopes).toEqual([ + "user:profile", + "user:inference", + "user:sessions:claude_code", + ]); + expect(r.refresh?.access_expires_at).toBe(new Date(1893456000000).toISOString()); + }); + + it("accepts a bare blob without the claudeAiOauth wrapper", () => { + const bare = JSON.stringify({ accessToken: "a", refreshToken: "b", expiresAt: 1893456000000 }); + expect(parseClaudeOauthBlob(bare).credentials.ANTHROPIC_BEARER_TOKEN).toBe("a"); + }); + + it("throws on non-JSON input", () => { + expect(() => parseClaudeOauthBlob("not json")).toThrow(/not valid JSON/); + }); + + it("throws when accessToken or refreshToken is missing", () => { + const noRefresh = JSON.stringify({ claudeAiOauth: { accessToken: "a" } }); + expect(() => parseClaudeOauthBlob(noRefresh)).toThrow(/accessToken\/refreshToken/); + }); + + it("falls back to a ~1h future expiry when expiresAt is absent/non-numeric", () => { + const noExp = JSON.stringify({ claudeAiOauth: { accessToken: "a", refreshToken: "b" } }); + const r = parseClaudeOauthBlob(noExp); + expect(new Date(r.refresh!.access_expires_at).getTime()).toBeGreaterThan(Date.now()); + }); + + it("defaults scopes when absent", () => { + const noScopes = JSON.stringify({ + claudeAiOauth: { accessToken: "a", refreshToken: "b", expiresAt: 1893456000000 }, + }); + expect(parseClaudeOauthBlob(noScopes).refresh?.scopes).toEqual([ + "user:profile", + "user:inference", + ]); + }); +}); + +describe("claudeKeychainService", () => { + it("matches CC's derivation: Claude Code-credentials-", () => { + const dir = "/tmp/openlock-cc-login-abc123"; + const want = `Claude Code-credentials-${createHash("sha256") + .update(dir.normalize("NFC")) + .digest("hex") + .slice(0, 8)}`; + expect(claudeKeychainService(dir)).toBe(want); + }); + + it("NFC-normalizes the dir before hashing", () => { + // Precomposed e-acute (U+00E9) vs decomposed e + combining acute (U+0065 U+0301): + // distinct byte sequences sharing one NFC form -> identical service name. + // Built with fromCodePoint so the source stays pure-ASCII (no literal accents). + const precomposed = `/tmp/caf${String.fromCodePoint(0x00e9)}`; + const decomposed = `/tmp/cafe${String.fromCodePoint(0x0301)}`; + expect(precomposed).not.toBe(decomposed); // genuinely different inputs + expect(claudeKeychainService(precomposed)).toBe(claudeKeychainService(decomposed)); + }); +}); + +function silentIO(): LoginIO { + return { + isTTY: true, + writeStdout() {}, + writeStderr() {}, + async readLine() { + return ""; + }, + }; +} + +const BLOB = JSON.stringify({ + claudeAiOauth: { accessToken: "AT", refreshToken: "RT", expiresAt: 1893456000000 }, +}); + +function baseDeps(over: Partial = {}): ImportDeps { + return { + platform: "linux", + hasClaude: () => true, + makeConfigDir: () => "/tmp/cfgX", + spawnLogin: async () => 0, + readFile: () => BLOB, + readKeychain: () => BLOB, + deleteKeychain: () => {}, + removeDir: () => {}, + ...over, + }; +} + +describe("importFromClaudeCode", () => { + it("on Linux reads .credentials.json from the throwaway dir", async () => { + let readPath = ""; + const deps = baseDeps({ + readFile: (p) => { + readPath = p; + return BLOB; + }, + }); + const r = await importFromClaudeCode(silentIO(), deps); + expect(r.credentials.ANTHROPIC_BEARER_TOKEN).toBe("AT"); + expect(readPath).toBe("/tmp/cfgX/.credentials.json"); + }); + + it("on macOS reads the computed keychain item and deletes it after", async () => { + let readService = ""; + let deletedService = ""; + const deps = baseDeps({ + platform: "darwin", + makeConfigDir: () => "/tmp/cfgMac", + readKeychain: (s) => { + readService = s; + return BLOB; + }, + deleteKeychain: (s) => { + deletedService = s; + }, + }); + const r = await importFromClaudeCode(silentIO(), deps); + expect(r.credentials.ANTHROPIC_BEARER_TOKEN).toBe("AT"); + expect(readService).toBe(claudeKeychainService("/tmp/cfgMac")); + expect(deletedService).toBe(claudeKeychainService("/tmp/cfgMac")); + }); + + it("on macOS falls back to .credentials.json when the keychain is empty", async () => { + // CC writes configDir/.credentials.json when the keychain write fails + // (locked/headless/SSH). readKeychain returns null → read the file instead. + let readPath = ""; + const deps = baseDeps({ + platform: "darwin", + makeConfigDir: () => "/tmp/cfgMac", + readKeychain: () => null, + readFile: (p) => { + readPath = p; + return BLOB; + }, + }); + const r = await importFromClaudeCode(silentIO(), deps); + expect(r.credentials.ANTHROPIC_BEARER_TOKEN).toBe("AT"); + expect(readPath).toBe("/tmp/cfgMac/.credentials.json"); + }); + + it("throws an actionable error when claude is not on PATH", async () => { + await expect( + importFromClaudeCode(silentIO(), baseDeps({ hasClaude: () => false })), + ).rejects.toThrow(/not found on PATH/); + }); + + it("throws when the login subprocess exits non-zero", async () => { + await expect( + importFromClaudeCode(silentIO(), baseDeps({ spawnLogin: async () => 1 })), + ).rejects.toThrow(/exited with code 1/); + }); + + it("throws when no credential was stored", async () => { + await expect( + importFromClaudeCode(silentIO(), baseDeps({ readFile: () => null })), + ).rejects.toThrow(/Could not read/); + }); + + it("always removes the throwaway dir, even on harvest failure", async () => { + let removed = false; + const deps = baseDeps({ + readFile: () => null, + removeDir: () => { + removed = true; + }, + }); + await expect(importFromClaudeCode(silentIO(), deps)).rejects.toThrow(); + expect(removed).toBe(true); + }); +}); diff --git a/src/providers/anthropic-import.ts b/src/providers/anthropic-import.ts new file mode 100644 index 0000000..61f5932 --- /dev/null +++ b/src/providers/anthropic-import.ts @@ -0,0 +1,177 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LoginIO, LoginResult } from "./types"; + +const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const DEFAULT_SCOPES = ["user:profile", "user:inference"]; + +/** Parse the `claudeAiOauth` credential Claude Code stores after `claude auth + * login` (macOS Keychain secret / Linux .credentials.json) into an openlock + * LoginResult. The access token is stored RAW (no "Bearer " prefix) — the + * gateway adds the prefix via the policy cred_inject value_prefix at egress. */ +export function parseClaudeOauthBlob(raw: string): LoginResult { + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + throw new Error("Claude Code credential is not valid JSON."); + } + const o = ((parsed.claudeAiOauth as Record) ?? parsed) as Record< + string, + unknown + >; + const accessToken = o.accessToken as string | undefined; + const refreshToken = o.refreshToken as string | undefined; + if (!accessToken || !refreshToken) { + throw new Error( + "Claude Code credential is missing accessToken/refreshToken (was this a subscription login?).", + ); + } + const expiresAtMs = + typeof o.expiresAt === "number" && Number.isFinite(o.expiresAt) + ? o.expiresAt + : Date.now() + 3600_000; + const scopes = + Array.isArray(o.scopes) && o.scopes.length > 0 ? (o.scopes as string[]) : [...DEFAULT_SCOPES]; + return { + credentials: { ANTHROPIC_BEARER_TOKEN: accessToken }, + refresh: { + strategy: "oauth2_refresh_token", + token_url: CLAUDE_OAUTH_TOKEN_URL, + scopes, + client_id: CLAUDE_OAUTH_CLIENT_ID, + refresh_token: refreshToken, + access_expires_at: new Date(expiresAtMs).toISOString(), + }, + }; +} + +/** The macOS Keychain service name Claude Code uses for its credential item, + * derived from its config dir `dir`. CC builds it as + * `Claude Code` + OAUTH_FILE_SUFFIX("" for a stock build) + "-credentials" + + * "-" + sha256(dir).hex.slice(0,8). realImportDeps points BOTH CLAUDE_CONFIG_DIR + * and CLAUDE_SECURESTORAGE_CONFIG_DIR at the same throwaway dir, so whichever CC + * keys the hash off, this computes the identical name — we read exactly the one + * item CC just created, never the user's real credential. (The `.normalize("NFC")` + * is a no-op for our ASCII mkdtemp paths; the file fallback in importFromClaudeCode + * covers any residual item-name mismatch.) */ +export function claudeKeychainService(dir: string): string { + const suffix = createHash("sha256").update(dir.normalize("NFC")).digest("hex").slice(0, 8); + return `Claude Code-credentials-${suffix}`; +} + +/** Injected I/O for importFromClaudeCode so the orchestration is testable + * without spawning `claude` or touching a real Keychain. */ +export interface ImportDeps { + platform: NodeJS.Platform; + hasClaude(): boolean; + /** Make + return a fresh throwaway config dir path. */ + makeConfigDir(): string; + /** Spawn `claude auth login --claudeai` with the throwaway config dir, + * inheriting the TTY. Resolves to the process exit code. */ + spawnLogin(configDir: string): Promise; + /** Read a credential file (Linux). null if absent/unreadable. */ + readFile(path: string): string | null; + /** Read a Keychain item secret by service name (macOS). null if absent. */ + readKeychain(service: string): string | null; + /** Delete a Keychain item by service name (macOS cleanup). Best-effort. */ + deleteKeychain(service: string): void; + /** Remove the throwaway config dir. Best-effort. */ + removeDir(dir: string): void; +} + +/** Orchestrate an isolated Claude Code subscription login and harvest the token + * it stores. The real subscription token lands only in openlock's own + * credentials file (via the returned LoginResult); the throwaway CC store is + * erased. The user's own Claude Code credentials are never touched. */ +export async function importFromClaudeCode(io: LoginIO, deps: ImportDeps): Promise { + if (!deps.hasClaude()) { + throw new Error( + "Claude Code CLI ('claude') not found on PATH. Install Claude Code (so `claude auth login` works), then retry `openlock login`.", + ); + } + const configDir = deps.makeConfigDir(); + const service = deps.platform === "darwin" ? claudeKeychainService(configDir) : null; + try { + io.writeStdout( + "Opening an isolated Claude Code subscription login. Complete the browser sign-in; your own Claude Code login is untouched.\n", + ); + const code = await deps.spawnLogin(configDir); + if (code !== 0) { + throw new Error(`Claude Code login exited with code ${code}.`); + } + // On macOS, Claude Code's credential store is a composite: it prefers the + // Keychain but falls back to writing configDir/.credentials.json when the + // Keychain is unavailable (locked, headless/CI, SSH session, access denied). + // So read the Keychain first, then fall back to the file — the same path the + // Linux branch reads. Reading the file is also a safety net against any + // Keychain item-name mismatch. + const credFile = join(configDir, ".credentials.json"); + const raw = + deps.platform === "darwin" + ? (deps.readKeychain(service as string) ?? deps.readFile(credFile)) + : deps.readFile(credFile); + if (!raw) { + throw new Error( + "Could not read the credential Claude Code stored after login. Did the subscription login complete?", + ); + } + return parseClaudeOauthBlob(raw); + } finally { + if (deps.platform === "darwin" && service) { + try { + deps.deleteKeychain(service); + } catch { + // best-effort cleanup + } + } + deps.removeDir(configDir); + } +} + +/** Production wiring for ImportDeps. */ +export function realImportDeps(): ImportDeps { + return { + platform: process.platform, + hasClaude: () => Bun.which("claude") !== null, + makeConfigDir: () => mkdtempSync(join(tmpdir(), "openlock-cc-login-")), + async spawnLogin(configDir: string): Promise { + const proc = Bun.spawn(["claude", "auth", "login", "--claudeai"], { + // Set BOTH: CLAUDE_CONFIG_DIR isolates all CC state; and + // CLAUDE_SECURESTORAGE_CONFIG_DIR makes the macOS Keychain item name + // deterministic (see claudeKeychainService). + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir, + CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir, + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + return await proc.exited; + }, + readFile(path: string): string | null { + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } + }, + readKeychain(service: string): string | null { + const r = Bun.spawnSync(["security", "find-generic-password", "-s", service, "-w"]); + if (r.exitCode !== 0) return null; + const out = r.stdout.toString().trim(); + return out.length > 0 ? out : null; + }, + deleteKeychain(service: string): void { + Bun.spawnSync(["security", "delete-generic-password", "-s", service]); + }, + removeDir(dir: string): void { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/providers/anthropic.test.ts b/src/providers/anthropic.test.ts index 310df89..f3c62e5 100644 --- a/src/providers/anthropic.test.ts +++ b/src/providers/anthropic.test.ts @@ -1,39 +1,30 @@ import { describe, expect, it } from "bun:test"; import { ANTHROPIC } from "./anthropic"; -describe("ANTHROPIC plugin", () => { +describe("ANTHROPIC plugin (OAuth subscription)", () => { it("declares identity and openshell type", () => { expect(ANTHROPIC.id).toBe("anthropic"); - expect(ANTHROPIC.openshellType).toBe("claude"); - expect(ANTHROPIC.credentialEnvVars).toEqual(["ANTHROPIC_BEARER_TOKEN", "ANTHROPIC_AUTH_TOKEN"]); + expect(ANTHROPIC.openshellType).toBe("claude-oauth"); + expect(ANTHROPIC.credentialEnvVars).toEqual(["ANTHROPIC_BEARER_TOKEN"]); }); - it("is compatible with claude_code and opencode", () => { + it("is compatible with claude_code only (not opencode)", () => { expect(ANTHROPIC.compatibleHarnesses.has("claude_code")).toBe(true); - expect(ANTHROPIC.compatibleHarnesses.has("opencode")).toBe(true); + expect(ANTHROPIC.compatibleHarnesses.has("opencode")).toBe(false); }); describe("policyEndpoints", () => { - it("uses OAuth-bearer cred_inject for claude_code", () => { + it("uses OAuth-bearer cred_inject with value_prefix for claude_code", () => { const endpoints = ANTHROPIC.policyEndpoints("claude_code"); expect(endpoints).toHaveLength(1); const e = endpoints[0]; expect(e.host).toBe("api.anthropic.com"); expect(e.cred_inject?.inject).toEqual([ - { header: "Authorization", from_credential: "ANTHROPIC_BEARER_TOKEN" }, - ]); - expect(e.cred_inject?.strip_headers).toContain("Authorization"); - expect(e.cred_inject?.strip_headers).toContain("x-api-key"); - expect(e.cred_inject?.strip_headers).toContain("Cookie"); - }); - - it("uses x-api-key cred_inject for opencode", () => { - const endpoints = ANTHROPIC.policyEndpoints("opencode"); - expect(endpoints).toHaveLength(1); - const e = endpoints[0]; - expect(e.host).toBe("api.anthropic.com"); - expect(e.cred_inject?.inject).toEqual([ - { header: "x-api-key", from_credential: "ANTHROPIC_API_KEY" }, + { + header: "Authorization", + from_credential: "ANTHROPIC_BEARER_TOKEN", + value_prefix: "Bearer ", + }, ]); expect(e.cred_inject?.strip_headers).toContain("Authorization"); expect(e.cred_inject?.strip_headers).toContain("x-api-key"); @@ -42,24 +33,35 @@ describe("ANTHROPIC plugin", () => { }); describe("sandboxEnvPlaceholders", () => { - it("returns empty for claude_code (OAuth-bearer flow)", () => { + it("returns empty for claude_code (OAuth-file flow, no env placeholder)", () => { expect(ANTHROPIC.sandboxEnvPlaceholders("claude_code")).toEqual({}); }); + }); + + describe("sandboxFiles", () => { + it("stages one OAuth-shaped .credentials.json for claude_code", () => { + const files = ANTHROPIC.sandboxFiles("claude_code"); + expect(files).toHaveLength(1); + const f = files[0]; + expect(f.sandboxPath).toBe("/sandbox/.openlock/claude-config/.credentials.json"); + const parsed = JSON.parse(f.content) as { + claudeAiOauth?: { accessToken?: string }; + }; + expect(parsed.claudeAiOauth?.accessToken).toMatch(/^sk-ant-oat01-/); + }); - it("returns ANTHROPIC_API_KEY placeholder for opencode", () => { - expect(ANTHROPIC.sandboxEnvPlaceholders("opencode")).toEqual({ - ANTHROPIC_API_KEY: "managed-by-openlock-do-not-leak", - }); + it("stages nothing for opencode", () => { + expect(ANTHROPIC.sandboxFiles("opencode")).toEqual([]); }); }); describe("redactionPatterns", () => { - it("matches Anthropic key shapes", () => { + it("matches oat01 and ort01 token shapes", () => { const patterns = ANTHROPIC.redactionPatterns(); const allMatch = (s: string) => patterns.some((re) => new RegExp(re.source).test(s)); expect(allMatch("sk-ant-oat01-AAAAAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true); - expect(allMatch("sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true); - expect(allMatch("Bearer sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true); + expect(allMatch("sk-ant-ort01-AAAAAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true); + expect(allMatch("Bearer sk-ant-oat01-AAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true); }); }); }); diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 91a9f81..ddbb39f 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,80 +1,93 @@ -import { spawn } from "bun"; import type { Harness } from "../sandbox/harness"; -import type { LoginIO, PolicyEndpointSpec, ProviderCredentials, ProviderPlugin } from "./types"; +import { importFromClaudeCode, realImportDeps } from "./anthropic-import"; +import type { + LoginIO, + LoginResult, + PolicyEndpointSpec, + ProviderPlugin, + SandboxFile, +} from "./types"; -async function runClaudeSetupToken(io: LoginIO): Promise { - io.writeStdout("Running `claude setup-token` to generate a long-lived OAuth token...\n"); - const proc = spawn({ - cmd: ["claude", "setup-token"], - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }); - const code = await proc.exited; - if (code !== 0) { - throw new Error("`claude setup-token` exited non-zero. Aborting login."); - } - return (await io.readLine("\nPaste the token printed above:\n> ")).trim(); -} +// Dummy OAuth-shaped credentials staged into the sandbox at +// /sandbox/.openlock/claude-config/.credentials.json. Its only job is to flip +// Claude Code into OAuth (subscription) mode — it NEVER authenticates. The +// gateway proxy strips the placeholder Authorization header and injects the +// real subscription access token at egress, so the real token never enters the +// sandbox. The OAT/ORT-shaped values are inert placeholders. +const DUMMY_CREDENTIALS_JSON = JSON.stringify({ + claudeAiOauth: { + accessToken: "sk-ant-oat01-openlock-placeholder-000000000000000000000000000000", + refreshToken: "sk-ant-ort01-openlock-placeholder-000000000000000000000000000000", + expiresAt: 4102444800000, // ~year 2100 in epoch-ms — Claude Code never treats this as expired + scopes: ["user:inference"], // inert placeholder; real scopes/subscriptionType are enforced gateway-side + subscriptionType: "max", + }, +}); export const ANTHROPIC: ProviderPlugin = { id: "anthropic", - displayName: "Anthropic (Claude)", - openshellType: "claude", - credentialEnvVars: ["ANTHROPIC_BEARER_TOKEN", "ANTHROPIC_AUTH_TOKEN"], - compatibleHarnesses: new Set(["claude_code", "opencode"]), + displayName: "Anthropic (Claude subscription)", + openshellType: "claude-oauth", + credentialEnvVars: ["ANTHROPIC_BEARER_TOKEN"], + // claude_code ONLY. The subscription OAuth flow flips Claude Code into OAuth + // mode via a staged .credentials.json; opencode has no such mechanism. Use + // OpenRouter (or the OpenCode Claude-auth plugin) with opencode instead. + compatibleHarnesses: new Set(["claude_code"]), - async loginInteractive(io: LoginIO): Promise { - const token = await runClaudeSetupToken(io); - if (!token) throw new Error("No token entered."); - return { - ANTHROPIC_BEARER_TOKEN: `Bearer ${token}`, - ANTHROPIC_AUTH_TOKEN: token, - }; + async loginInteractive(io: LoginIO): Promise { + // Import the subscription token from an isolated Claude Code login rather + // than reimplementing Claude's OAuth handshake (which proved fragile across + // endpoint/scope changes). Claude Code's login is always-correct by + // construction; the harvested raw token carries NO "Bearer " prefix — the + // gateway prepends it via the policy cred_inject value_prefix at egress. + return importFromClaudeCode(io, realImportDeps()); }, - policyEndpoints(harness: Harness): readonly PolicyEndpointSpec[] { - const base = { - host: "api.anthropic.com", - port: 443, - protocol: "rest" as const, - rules: [{ allow: { method: "POST", path: "/v1/**" } }], - }; - if (harness === "claude_code") { - return [ - { - ...base, - cred_inject: { - provider: "anthropic", - strip_headers: ["Authorization", "x-api-key", "Cookie"], - inject: [{ header: "Authorization", from_credential: "ANTHROPIC_BEARER_TOKEN" }], - }, - }, - ]; - } - // opencode and future providers share the API-key path + policyEndpoints(_harness: Harness): readonly PolicyEndpointSpec[] { return [ { - ...base, + host: "api.anthropic.com", + port: 443, + protocol: "rest", + rules: [{ allow: { method: "POST", path: "/v1/**" } }], cred_inject: { provider: "anthropic", strip_headers: ["Authorization", "x-api-key", "Cookie"], - inject: [{ header: "x-api-key", from_credential: "ANTHROPIC_API_KEY" }], + // RAW token stored; gateway adds the "Bearer " prefix at egress. + inject: [ + { + header: "Authorization", + from_credential: "ANTHROPIC_BEARER_TOKEN", + value_prefix: "Bearer ", + }, + ], }, }, ]; }, - sandboxEnvPlaceholders(harness: Harness): Record { - if (harness === "claude_code") return {}; - return { ANTHROPIC_API_KEY: "managed-by-openlock-do-not-leak" }; + // OAuth-file flow: Claude Code reads the staged .credentials.json, so no env + // placeholder is needed. + sandboxEnvPlaceholders(_harness: Harness): Record { + return {}; + }, + + sandboxFiles(harness: Harness): readonly SandboxFile[] { + if (harness !== "claude_code") return []; + return [ + { + sandboxPath: "/sandbox/.openlock/claude-config/.credentials.json", + content: DUMMY_CREDENTIALS_JSON, + }, + ]; }, redactionPatterns(): readonly RegExp[] { return [ /sk-ant-oat[0-9]{2}-[a-zA-Z0-9_-]{20,}/g, - /sk-ant-[a-zA-Z0-9_-]{20,}/g, + /sk-ant-ort[0-9]{2}-[a-zA-Z0-9_-]{20,}/g, /Bearer\s+sk-ant-[a-zA-Z0-9_-]{20,}/gi, + /sk-ant-[a-zA-Z0-9_-]{20,}/g, ]; }, }; diff --git a/src/providers/openrouter.test.ts b/src/providers/openrouter.test.ts index 05f4b1a..49ecb4d 100644 --- a/src/providers/openrouter.test.ts +++ b/src/providers/openrouter.test.ts @@ -26,12 +26,14 @@ describe("OPENROUTER plugin", () => { }); describe("loginInteractive", () => { - it("returns { OPENROUTER_BEARER_TOKEN } with Bearer prefix when prefix and length valid", async () => { - const creds = await OPENROUTER.loginInteractive( + it("returns { credentials: { OPENROUTER_BEARER_TOKEN } } with Bearer prefix when prefix and length valid", async () => { + const result = await OPENROUTER.loginInteractive( makeIO("sk-or-v1-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), ); - expect(creds).toEqual({ - OPENROUTER_BEARER_TOKEN: "Bearer sk-or-v1-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + expect(result).toEqual({ + credentials: { + OPENROUTER_BEARER_TOKEN: "Bearer sk-or-v1-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, }); }); it("rejects empty input", async () => { @@ -48,10 +50,12 @@ describe("OPENROUTER plugin", () => { ); }); it("trims whitespace before validation", async () => { - const creds = await OPENROUTER.loginInteractive( + const result = await OPENROUTER.loginInteractive( makeIO(" sk-or-v1-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"), ); - expect(creds.OPENROUTER_BEARER_TOKEN).toBe("Bearer sk-or-v1-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + expect(result.credentials.OPENROUTER_BEARER_TOKEN).toBe( + "Bearer sk-or-v1-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); }); }); diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index 611d109..3680d10 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -1,5 +1,5 @@ import type { Harness } from "../sandbox/harness"; -import type { LoginIO, PolicyEndpointSpec, ProviderCredentials, ProviderPlugin } from "./types"; +import type { LoginIO, LoginResult, PolicyEndpointSpec, ProviderPlugin } from "./types"; function validateOpenRouterKey(raw: string): string { const k = raw.trim(); @@ -20,10 +20,10 @@ export const OPENROUTER: ProviderPlugin = { credentialEnvVars: ["OPENROUTER_BEARER_TOKEN"], compatibleHarnesses: new Set(["opencode"]), - async loginInteractive(io: LoginIO): Promise { + async loginInteractive(io: LoginIO): Promise { const raw = await io.readLine("Paste your OpenRouter API key (starts with sk-or-):\n> "); const key = validateOpenRouterKey(raw); - return { OPENROUTER_BEARER_TOKEN: `Bearer ${key}` }; + return { credentials: { OPENROUTER_BEARER_TOKEN: `Bearer ${key}` } }; }, policyEndpoints(_harness: Harness): readonly PolicyEndpointSpec[] { @@ -65,6 +65,8 @@ export const OPENROUTER: ProviderPlugin = { return { OPENROUTER_API_KEY: "managed-by-openlock-do-not-leak" }; }, + sandboxFiles: () => [], + redactionPatterns(): readonly RegExp[] { return [/sk-or-v1-[a-zA-Z0-9_-]{20,}/g, /sk-or-[a-zA-Z0-9_-]{20,}/g]; }, diff --git a/src/providers/registry.test.ts b/src/providers/registry.test.ts index 4b380ad..23aefec 100644 --- a/src/providers/registry.test.ts +++ b/src/providers/registry.test.ts @@ -19,6 +19,17 @@ describe("registry", () => { expect(PROVIDERS[id].compatibleHarnesses.size).toBeGreaterThan(0); } }); + + it("every plugin exposes sandboxFiles returning an array", () => { + for (const id of PROVIDER_IDS) { + const files = PROVIDERS[id].sandboxFiles("claude_code"); + expect(Array.isArray(files)).toBe(true); + } + }); + + it("openrouter.sandboxFiles returns []", () => { + expect(PROVIDERS.openrouter.sandboxFiles("opencode")).toEqual([]); + }); }); describe("validateProviderId", () => { diff --git a/src/providers/resolve.test.ts b/src/providers/resolve.test.ts index bd133bd..2e1fe5c 100644 --- a/src/providers/resolve.test.ts +++ b/src/providers/resolve.test.ts @@ -52,7 +52,7 @@ describe("resolveProvider precedence", () => { it("global config used when neither flag nor env set", () => { expect( resolveProvider({ - harness: "opencode", + harness: "claude_code", cliFlag: undefined, env: {}, readGlobalConfig: () => ({ defaultProvider: "anthropic" }), @@ -72,6 +72,17 @@ describe("resolveProvider compatibility", () => { }), ).toThrow(/not compatible/); }); + + it("rejects anthropic for opencode with an actionable hint", () => { + expect(() => + resolveProvider({ + harness: "opencode", + cliFlag: "anthropic", + env: {}, + readGlobalConfig: noGlobal, + }), + ).toThrow(/not compatible.*opencode.*(openrouter|OpenCode)/is); + }); }); describe("resolveProvider missing-signal cases", () => { diff --git a/src/providers/resolve.ts b/src/providers/resolve.ts index 4e9be69..7f9ab15 100644 --- a/src/providers/resolve.ts +++ b/src/providers/resolve.ts @@ -13,8 +13,16 @@ function ensureCompatible(id: ProviderId, harness: Harness): void { const plugin = PROVIDERS[id]; if (!plugin.compatibleHarnesses.has(harness)) { const compatible = [...plugin.compatibleHarnesses].join(", "); + let hint = ""; + // The Anthropic subscription provider only works with claude_code (it flips + // Claude Code into OAuth mode via a staged .credentials.json). Point + // opencode users at the supported alternatives instead of a dead end. + if (id === "anthropic" && harness === "opencode") { + hint = + " To use Claude with opencode, choose --provider openrouter, or wire up the OpenCode Claude-auth plugin."; + } throw new Error( - `Provider '${id}' is not compatible with harness '${harness}'. Compatible harnesses: ${compatible}.`, + `Provider '${id}' is not compatible with harness '${harness}'. Compatible harnesses: ${compatible}.${hint}`, ); } } diff --git a/src/providers/types.test.ts b/src/providers/types.test.ts index 0a4c796..edc2ac6 100644 --- a/src/providers/types.test.ts +++ b/src/providers/types.test.ts @@ -41,7 +41,7 @@ describe("provider types", () => { credentialEnvVars: ["X"], compatibleHarnesses: new Set(["claude_code"]), async loginInteractive() { - return {}; + return { credentials: {} }; }, policyEndpoints() { return []; @@ -49,6 +49,9 @@ describe("provider types", () => { sandboxEnvPlaceholders() { return {}; }, + sandboxFiles() { + return []; + }, redactionPatterns() { return []; }, diff --git a/src/providers/types.ts b/src/providers/types.ts index 2b031b8..2cd7a53 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1,12 +1,21 @@ import type { Harness } from "../sandbox/harness"; +import type { ProviderRefreshMaterial } from "../tokens"; export type ProviderId = "anthropic" | "openrouter"; -type OpenshellProviderType = "claude" | "generic"; +type OpenshellProviderType = "claude" | "claude-oauth" | "generic"; export interface ProviderCredentials { [envName: string]: string; } +/** Outcome of an interactive login: the credentials to store plus optional + * gateway-side refresh material (for OAuth providers whose access token expires + * and must be refreshed without a new interactive login). */ +export interface LoginResult { + credentials: ProviderCredentials; + refresh?: ProviderRefreshMaterial; +} + export interface LoginIO { readLine(prompt: string): Promise; writeStdout(s: string): void; @@ -17,7 +26,13 @@ export interface LoginIO { interface CredInjectSpec { provider: ProviderId; strip_headers: readonly string[]; - inject: ReadonlyArray<{ header: string; from_credential: string }>; + inject: ReadonlyArray<{ header: string; from_credential: string; value_prefix?: string }>; +} + +export interface SandboxFile { + /** Absolute sandbox path under /sandbox/.openlock/. */ + sandboxPath: string; + content: string; } export interface PolicyEndpointSpec { @@ -38,9 +53,13 @@ export interface ProviderPlugin { /** Env-var names under which credentials are stored in the openlock credentials file. These are not necessarily the env vars injected into the sandbox. */ readonly credentialEnvVars: readonly string[]; readonly compatibleHarnesses: ReadonlySet; - loginInteractive(io: LoginIO): Promise; + loginInteractive(io: LoginIO): Promise; policyEndpoints(harness: Harness): readonly PolicyEndpointSpec[]; /** Returns placeholder strings, not real credential values — the real credential never enters the sandbox. The gateway strip-replaces placeholders with real values at HTTP egress. */ sandboxEnvPlaceholders(harness: Harness): Record; + /** Files staged into the sandbox under /sandbox/.openlock/. These carry no + * real secrets — placeholders the gateway swaps at egress (e.g. a dummy + * OAuth-shaped .credentials.json that flips Claude Code into OAuth mode). */ + sandboxFiles(harness: Harness): readonly SandboxFile[]; redactionPatterns(): readonly RegExp[]; } diff --git a/src/sandbox/claude-oauth-profile.test.ts b/src/sandbox/claude-oauth-profile.test.ts new file mode 100644 index 0000000..18cd3d4 --- /dev/null +++ b/src/sandbox/claude-oauth-profile.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import yaml from "js-yaml"; +import type { ProviderRefreshMaterial } from "../tokens"; +import { buildClaudeOAuthProfileYaml } from "./claude-oauth-profile"; + +const material: ProviderRefreshMaterial = { + strategy: "oauth2_refresh_token", + token_url: "https://platform.claude.com/v1/oauth/token", + scopes: ["org:create_api_key", "user:profile", "user:inference"], + client_id: "client-abc", + refresh_token: "rt-secret", + access_expires_at: "2026-06-12T12:00:00Z", +}; + +describe("buildClaudeOAuthProfileYaml", () => { + // biome-ignore lint/suspicious/noExplicitAny: parsed YAML is dynamically shaped in tests. + const parsed = yaml.load(buildClaudeOAuthProfileYaml(material)) as any; + + it("has top-level id 'claude-oauth'", () => { + expect(parsed.id).toBe("claude-oauth"); + }); + + it("declares exactly one credential named ANTHROPIC_BEARER_TOKEN", () => { + expect(parsed.credentials).toHaveLength(1); + expect(parsed.credentials[0].name).toBe("ANTHROPIC_BEARER_TOKEN"); + }); + + it("uses the snake_case refresh strategy", () => { + expect(parsed.credentials[0].refresh.strategy).toBe("oauth2_refresh_token"); + }); + + it("carries token_url and scopes from the material", () => { + expect(parsed.credentials[0].refresh.token_url).toBe(material.token_url); + expect(parsed.credentials[0].refresh.scopes).toEqual(material.scopes); + }); + + it("declares the material SCHEMA only (no client_id/refresh_token values)", () => { + expect(parsed.credentials[0].refresh.material).toEqual([ + { name: "client_id", required: true }, + { name: "refresh_token", required: true, secret: true }, + ]); + const serialized = buildClaudeOAuthProfileYaml(material); + expect(serialized).not.toContain(material.client_id); + expect(serialized).not.toContain(material.refresh_token); + }); +}); diff --git a/src/sandbox/claude-oauth-profile.ts b/src/sandbox/claude-oauth-profile.ts new file mode 100644 index 0000000..2782744 --- /dev/null +++ b/src/sandbox/claude-oauth-profile.ts @@ -0,0 +1,55 @@ +import yaml from "js-yaml"; +import type { ProviderRefreshMaterial } from "../tokens"; + +/** The custom openshell provider-profile id for the Claude OAuth subscription + * provider. Used as the profile's `id` and as the key for the existence probe + * (`provider profile export `) that gates the non-idempotent import. */ +export const CLAUDE_OAUTH_PROFILE_ID = "claude-oauth"; + +/** + * Build the openshell runtime provider-profile YAML for the Claude OAuth + * subscription provider. Importing this profile (when absent) gives the + * gateway the `token_url`, `scopes`, and `refresh_before_seconds` it needs to + * mint fresh access tokens — these are NOT expressible as `provider refresh + * configure` flags, so they must come from the profile. + * + * Only the material SCHEMA (which keys exist, which are secret) lives in the + * profile; the actual `client_id` / `refresh_token` VALUES are supplied later + * via `--material` at configure time and never written to this file. + */ +export function buildClaudeOAuthProfileYaml(m: ProviderRefreshMaterial): string { + return yaml.dump({ + id: CLAUDE_OAUTH_PROFILE_ID, + display_name: "Claude (OAuth subscription)", + category: "agent", + inference_capable: true, + credentials: [ + { + name: "ANTHROPIC_BEARER_TOKEN", + env_vars: ["ANTHROPIC_BEARER_TOKEN"], + required: false, + auth_style: "header", + header_name: "authorization", + refresh: { + strategy: "oauth2_refresh_token", + token_url: m.token_url, + scopes: m.scopes, + refresh_before_seconds: 300, + material: [ + { name: "client_id", required: true }, + { name: "refresh_token", required: true, secret: true }, + ], + }, + }, + ], + endpoints: [ + { + host: "api.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + ], + }); +} diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index 00593b8..391ae56 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -12,11 +12,26 @@ import { buildSandboxStopArgv, buildSandboxUploadArgv, parseSandboxGetPhase, + TETHER_STDIO, wrapCmdWithEnv, } from "./container"; const CLI = ["openshell"] as const; +describe("TETHER_STDIO (openlock-sqw regression guard)", () => { + // The container tether outlives the CLI; if it inherits stdout, a detached + // create (`openlock sandbox --no-attach`) hangs any piped/CI stdout capture + // after the CLI process.exit()s. Tripwire against re-`inherit`ing it. + it("never inherits the CLI's stdout/stderr", () => { + expect(TETHER_STDIO.stdout).not.toBe("inherit"); + expect(TETHER_STDIO.stderr).not.toBe("inherit"); + }); + + it("ignores stdin (no parent stdin held)", () => { + expect(TETHER_STDIO.stdin).toBe("ignore"); + }); +}); + describe("wrapCmdWithEnv", () => { it("returns cmd unchanged when env is empty", () => { expect(wrapCmdWithEnv(["claude"], {})).toEqual(["claude"]); @@ -323,13 +338,17 @@ describe("buildSandboxEnv (provider placeholders)", () => { expect(env.ANTHROPIC_API_KEY).toBeUndefined(); }); - it("injects ANTHROPIC_API_KEY placeholder for opencode+anthropic", () => { + it("injects no provider env placeholder for anthropic+claude_code (OAuth-file flow)", () => { + // anthropic is now claude_code-only and uses a staged .credentials.json, + // not an env placeholder. The previous opencode+anthropic x-api-key path no + // longer exists. const env = buildSandboxEnv({ providerId: "anthropic", - harness: "opencode", + harness: "claude_code", repoConfigEnv: {}, }); - expect(env.ANTHROPIC_API_KEY).toBe("managed-by-openlock-do-not-leak"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); }); it("repo-config env wins over placeholder when user explicitly sets the same key", () => { @@ -340,6 +359,24 @@ describe("buildSandboxEnv (provider placeholders)", () => { }); expect(env.OPENROUTER_API_KEY).toBe("user-explicitly-set"); }); + + it("sets CLAUDE_CONFIG_DIR for claude_code harness", () => { + const env = buildSandboxEnv({ + providerId: "anthropic", + harness: "claude_code", + repoConfigEnv: {}, + }); + expect(env.CLAUDE_CONFIG_DIR).toBe("/sandbox/.openlock/claude-config"); + }); + + it("does NOT set CLAUDE_CONFIG_DIR for opencode harness", () => { + const env = buildSandboxEnv({ + providerId: "anthropic", + harness: "opencode", + repoConfigEnv: {}, + }); + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); + }); }); describe("buildOpenshellCreateArgv", () => { diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index 47357d1..cdb00e9 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -70,7 +70,12 @@ export interface BuildSandboxEnvArgs { export function buildSandboxEnv(args: BuildSandboxEnvArgs): Record { const placeholders = PROVIDERS[args.providerId].sandboxEnvPlaceholders(args.harness); - return { ...placeholders, ...args.repoConfigEnv }; + // Claude Code reads OAuth/config state (the staged .credentials.json) from + // CLAUDE_CONFIG_DIR. opencode doesn't use it. The dir is staged under + // /sandbox/.openlock/ and provisioned by createSession's bootstrap. + const harnessEnv: Record = + args.harness === "claude_code" ? { CLAUDE_CONFIG_DIR: "/sandbox/.openlock/claude-config" } : {}; + return { ...placeholders, ...harnessEnv, ...args.repoConfigEnv }; } export async function execHarness( @@ -155,15 +160,30 @@ export function buildOpenshellCreateArgv(args: OpenshellCreateArgs): string[] { ]; } +/** + * Stdio for a child that OUTLIVES the openlock CLI — the persistent sandbox + * tether (`… exec sleep infinity`). + * + * INVARIANT (openlock-sqw): a child that survives the CLI must NEVER set + * stdout/stderr to `"inherit"`. A detached create (`openlock sandbox + * --no-attach`) returns via `process.exit` while the tether keeps running; an + * inherited stdout fd would keep a piped/captured caller's stream open forever, + * hanging `SESSION=$(openlock sandbox --no-attach …)` and any CI capture. So + * stdout is discarded and stderr is `"pipe"` + drained (also not the parent's + * fd), surfaced filtered. The gateway daemon obeys the same rule a different + * way — `spawnDaemonToLog` redirects to a log fd and `unref`s. Any new + * long-lived `Bun.spawn` MUST follow one of these two patterns, never inherit. + */ +export const TETHER_STDIO = { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", +} as const; + export function openshellSandboxCreateAsync(args: OpenshellCreateArgs): Promise { return getCliInvocation().then((cli) => { const argv = [...cli.argv, ...buildOpenshellCreateArgv(args)]; - const proc = Bun.spawn(argv, { - cwd: cli.cwd, - stdin: "ignore", - stdout: "inherit", - stderr: "pipe", - }); + const proc = Bun.spawn(argv, { cwd: cli.cwd, ...TETHER_STDIO }); void pipeFilteredStderr(proc.stderr); return { pid: proc.pid, diff --git a/src/sandbox/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index 73f127f..73305b6 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -44,23 +44,37 @@ describe("providerExistsInGateway", () => { }); }); +interface MockState { + existing: string[]; + profilePresent?: boolean; +} + +const ok = (stdout = "") => ({ exitCode: 0, stdout, stderr: "" }); + +// Flat fake-gateway responder (kept out of the closure to bound complexity). +// `provider profile import` is non-idempotent in the real gateway, so the probe +// (`profile export`) reports present/absent and `import` flips it to present. +function fakeGateway(args: string[], state: MockState) { + const sub = `${args[1] ?? ""} ${args[2] ?? ""}`; + if (args[1] === "list") { + return ok(`NAME TYPE\n${state.existing.map((n) => `${n} generic`).join("\n")}\n`); + } + if (sub === "profile export") { + return { exitCode: state.profilePresent ? 0 : 1, stdout: "", stderr: "" }; + } + if (sub === "profile import") state.profilePresent = true; + if (args[1] === "create") state.existing.push(args[args.indexOf("--name") + 1]); + return ok(); +} + describe("_ensureProviderForTests", () => { - function makeShell(state: { existing: string[] }) { + function makeShell(state: MockState) { const calls: string[][] = []; return { calls, shell: async (args: string[]) => { calls.push(args); - if (args[0] === "provider" && args[1] === "list") { - return { - exitCode: 0, - stdout: `NAME TYPE\n${state.existing.map((n) => `${n} generic`).join("\n")}\n`, - stderr: "", - }; - } - // create / update: pretend success and mutate state for create - if (args[1] === "create") state.existing.push(args[args.indexOf("--name") + 1]); - return { exitCode: 0, stdout: "", stderr: "" }; + return fakeGateway(args, state); }, }; } @@ -97,4 +111,123 @@ describe("_ensureProviderForTests", () => { const m = makeShell({ existing: [] }); await expect(_ensureProviderForTests("openrouter", m.shell)).rejects.toThrow(/No credentials/); }); + + describe("anthropic OAuth refresh branch", () => { + function writeAnthropic() { + writeProvider("anthropic", { + type: "claude-oauth", + credentials: { ANTHROPIC_BEARER_TOKEN: "raw-access-token" }, + created_at: "t", + refresh: { + strategy: "oauth2_refresh_token", + token_url: "https://platform.claude.com/v1/oauth/token", + scopes: ["user:inference"], + client_id: "client-abc", + refresh_token: "rt-secret", + access_expires_at: "2026-06-12T12:00:00Z", + }, + }); + } + + function verb(calls: string[][], a: string, b: string): string[] | undefined { + return calls.find((c) => c[0] === "provider" && c[1] === a && c[2] === b); + } + + it("seeds once when absent: import, create, update, refresh-configure in order", async () => { + writeAnthropic(); + const m = makeShell({ existing: [] }); + await _ensureProviderForTests("anthropic", m.shell); + + const imp = verb(m.calls, "profile", "import"); + const create = m.calls.find((c) => c[1] === "create"); + const update = m.calls.find((c) => c[1] === "update"); + const configure = verb(m.calls, "refresh", "configure"); + + expect(imp).toBeDefined(); + expect(create).toBeDefined(); + expect(update).toBeDefined(); + expect(configure).toBeDefined(); + + // ordering: import < create < update < configure + const idx = (target: string[]) => m.calls.indexOf(target); + expect(idx(imp as string[])).toBeLessThan(idx(create as string[])); + expect(idx(create as string[])).toBeLessThan(idx(update as string[])); + expect(idx(update as string[])).toBeLessThan(idx(configure as string[])); + + // create uses --type claude-oauth and the raw access token + expect(create).toContain("--type"); + expect(create?.[create.indexOf("--type") + 1]).toBe("claude-oauth"); + expect(create).toContain("ANTHROPIC_BEARER_TOKEN=raw-access-token"); + + // update seeds credential expiry + expect(update).toContain("--credential-expires-at"); + expect(update).toContain("ANTHROPIC_BEARER_TOKEN=2026-06-12T12:00:00Z"); + + // refresh configure: NAME is positional (not --name), kebab strategy, + // material values, secret-material-key, and its OWN expires-at. + expect(configure?.[3]).toBe("anthropic"); + expect(configure).not.toContain("--name"); + expect(configure).toContain("--strategy"); + expect(configure?.[configure.indexOf("--strategy") + 1]).toBe("oauth2-refresh-token"); + expect(configure).toContain("--material"); + expect(configure).toContain("client_id=client-abc"); + expect(configure).toContain("refresh_token=rt-secret"); + expect(configure).toContain("--secret-material-key"); + expect(configure?.[configure.indexOf("--secret-material-key") + 1]).toBe("refresh_token"); + expect(configure).toContain("--credential-expires-at"); + expect(configure).toContain("2026-06-12T12:00:00Z"); + }); + + it("never clobbers when present: no create/update/refresh-configure", async () => { + writeAnthropic(); + const m = makeShell({ existing: ["anthropic"], profilePresent: true }); + await _ensureProviderForTests("anthropic", m.shell); + + expect(m.calls.find((c) => c[1] === "create")).toBeUndefined(); + expect(m.calls.find((c) => c[1] === "update")).toBeUndefined(); + expect(verb(m.calls, "refresh", "configure")).toBeUndefined(); + // Profile already present → probed via export, NOT re-imported (import is + // not idempotent — re-importing an existing id errors). + expect(verb(m.calls, "profile", "export")).toBeDefined(); + expect(verb(m.calls, "profile", "import")).toBeUndefined(); + }); + + it("re-seeds without re-importing when the profile already exists (provider deleted, profile lingering)", async () => { + // Regression for the reattach/re-seed crash: a prior session left the + // `claude-oauth` profile registered; deleting the provider and re-running + // must seed create/update/configure WITHOUT re-importing the profile + // (which would error "already exists"). + writeAnthropic(); + const m = makeShell({ existing: [], profilePresent: true }); + await _ensureProviderForTests("anthropic", m.shell); + + expect(verb(m.calls, "profile", "export")).toBeDefined(); + expect(verb(m.calls, "profile", "import")).toBeUndefined(); + expect(m.calls.find((c) => c[1] === "create")).toBeDefined(); + expect(m.calls.find((c) => c[1] === "update")).toBeDefined(); + expect(verb(m.calls, "refresh", "configure")).toBeDefined(); + }); + + it("throws on the seed path when refresh material lacks ANTHROPIC_BEARER_TOKEN", async () => { + writeProvider("anthropic", { + type: "claude-oauth", + credentials: {}, + created_at: "t", + refresh: { + strategy: "oauth2_refresh_token", + token_url: "https://platform.claude.com/v1/oauth/token", + scopes: ["user:inference"], + client_id: "client-abc", + refresh_token: "rt-secret", + access_expires_at: "2026-06-12T12:00:00Z", + }, + }); + const m = makeShell({ existing: [] }); + await expect(_ensureProviderForTests("anthropic", m.shell)).rejects.toThrow( + /no ANTHROPIC_BEARER_TOKEN credential/, + ); + // create must NOT have run with an undefined credential. + expect(m.calls.find((c) => c[1] === "create")).toBeUndefined(); + }); + }); }); diff --git a/src/sandbox/ensure-provider.ts b/src/sandbox/ensure-provider.ts index 92b7879..4965efd 100644 --- a/src/sandbox/ensure-provider.ts +++ b/src/sandbox/ensure-provider.ts @@ -1,6 +1,11 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { PROVIDERS } from "../providers/registry"; import type { ProviderId } from "../providers/types"; +import type { ProviderRecord } from "../tokens"; import { readProvider } from "../tokens"; +import { buildClaudeOAuthProfileYaml, CLAUDE_OAUTH_PROFILE_ID } from "./claude-oauth-profile"; import { getCliInvocation } from "./fork-binaries"; interface ShellResult { @@ -9,6 +14,23 @@ interface ShellResult { stderr: string; } +type Shell = (args: string[]) => Promise; + +// Throw-on-nonzero helper for the multi-step refresh-seeding sequence, where a +// per-call custom message isn't worth it (the raw command + stderr is enough to +// diagnose). The generic path keeps its own friendlier inline `Failed to +// create/update provider` throw on purpose — do NOT unify that into mustOk. +/** Run an openshell command, throwing (with stderr) on a non-zero exit. */ +async function mustOk(shell: Shell, args: string[]): Promise { + const result = await shell(args); + if (result.exitCode !== 0) { + throw new Error( + `openshell ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`, + ); + } + return result; +} + async function realOpenshell(args: string[]): Promise { const cli = await getCliInvocation(); const proc = Bun.spawn([...cli.argv, ...args], { @@ -37,14 +59,119 @@ export function providerExistsInGateway(listStdout: string, providerId: Provider .some((line) => line.trim().split(/\s+/)[0] === providerId); } -export async function ensureProvider(providerId: ProviderId): Promise { - await _ensureProviderForTests(providerId, realOpenshell); +/** Defensive: warn if the plugin's declared openshellType drifts from the + * stored record. Single code path shared by both the generic and refresh + * branches so the warning text/condition can't diverge. */ +function warnOnTypeDrift(providerId: ProviderId, record: ProviderRecord): void { + if (PROVIDERS[providerId].openshellType !== record.type) { + console.warn( + `openlock: provider '${providerId}' stored type='${record.type}' differs from plugin openshellType='${PROVIDERS[providerId].openshellType}'.`, + ); + } } -export async function _ensureProviderForTests( +/** + * Seed the gateway for a refresh-capable provider (e.g. the Claude OAuth + * subscription provider). + * + * Always imports the runtime profile (idempotent — verified Phase 0.1) so the + * gateway has token_url + scopes + refresh_before_seconds. + * + * NEVER-CLOBBER INVARIANT: `provider create`, `provider update + * --credential-expires-at`, and `provider refresh configure` run ONLY when the + * provider is ABSENT (`!exists`). When the provider is already PRESENT the + * gateway may have refreshed the access token itself; re-pushing the host token + * would replace that fresh token with the now-stale host one. So on a present + * provider we do nothing but the idempotent profile import. + */ +async function seedRefreshProvider( providerId: ProviderId, - shell: (args: string[]) => Promise, + record: ProviderRecord, + exists: boolean, + shell: Shell, ): Promise { + // record.refresh is non-undefined in this branch (callers gate on it). + const refresh = record.refresh; + if (!refresh) { + throw new Error(`seedRefreshProvider called for '${providerId}' without refresh material`); + } + + // `provider profile import` is NOT idempotent — re-importing an existing + // profile id errors ("already exists"). Probe with `provider profile export` + // (exit 0 = present) and import ONLY when absent, so reattaches and re-seeds + // (provider deleted but the profile still registered) don't crash. Run on + // every ensure (not gated on the provider's existence) so a profile that was + // somehow lost is restored, while a present one is left untouched. + const profilePresent = + (await shell(["provider", "profile", "export", CLAUDE_OAUTH_PROFILE_ID])).exitCode === 0; + if (!profilePresent) { + const dir = mkdtempSync(join(tmpdir(), "olk-prof-")); + try { + const profPath = join(dir, `${CLAUDE_OAUTH_PROFILE_ID}.yaml`); + writeFileSync(profPath, buildClaudeOAuthProfileYaml(refresh)); + // mustOk awaits, so import completes before the finally removes the dir. + await mustOk(shell, ["provider", "profile", "import", "--file", profPath]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + if (!exists) { + const access = record.credentials.ANTHROPIC_BEARER_TOKEN; + if (!access) { + throw new Error( + `Provider '${providerId}' record has refresh material but no ANTHROPIC_BEARER_TOKEN credential; re-run \`openlock login\`.`, + ); + } + await mustOk(shell, [ + "provider", + "create", + "--name", + providerId, + "--type", + record.type, + "--credential", + `ANTHROPIC_BEARER_TOKEN=${access}`, + ]); + await mustOk(shell, [ + "provider", + "update", + providerId, + "--credential-expires-at", + `ANTHROPIC_BEARER_TOKEN=${refresh.access_expires_at}`, + ]); + // NOTE: provider NAME is POSITIONAL here (not --name); the CLI strategy + // token is kebab-case `oauth2-refresh-token` (the stored/profile value is + // snake `oauth2_refresh_token`); and refresh configure needs its OWN + // --credential-expires-at to seed the refresh worker's next_refresh. + await mustOk(shell, [ + "provider", + "refresh", + "configure", + providerId, + "--credential-key", + "ANTHROPIC_BEARER_TOKEN", + "--strategy", + "oauth2-refresh-token", + "--material", + `client_id=${refresh.client_id}`, + "--material", + `refresh_token=${refresh.refresh_token}`, + "--secret-material-key", + "refresh_token", + "--credential-expires-at", + refresh.access_expires_at, + ]); + } + + warnOnTypeDrift(providerId, record); +} + +export async function ensureProvider(providerId: ProviderId): Promise { + await _ensureProviderForTests(providerId, realOpenshell); +} + +export async function _ensureProviderForTests(providerId: ProviderId, shell: Shell): Promise { const record = readProvider(providerId); if (!record) { throw new Error( @@ -58,6 +185,15 @@ export async function _ensureProviderForTests( } const exists = providerExistsInGateway(list.stdout, providerId); + // Gateway-native credential refresh (e.g. the Claude OAuth subscription + // provider): delegate to seedRefreshProvider, which imports the runtime + // profile idempotently and seeds create/update/configure ONCE (never + // re-pushing the host token when the provider already exists). + if (record.refresh) { + await seedRefreshProvider(providerId, record, exists, shell); + return; + } + const credArgs = Object.entries(record.credentials).flatMap(([k, v]) => [ "--credential", `${k}=${v}`, @@ -73,10 +209,5 @@ export async function _ensureProviderForTests( ); } - // Defensive: warn if the plugin's declared openshellType drifts from the stored record. - if (PROVIDERS[providerId].openshellType !== record.type) { - console.warn( - `openlock: provider '${providerId}' stored type='${record.type}' differs from plugin openshellType='${PROVIDERS[providerId].openshellType}'.`, - ); - } + warnOnTypeDrift(providerId, record); } diff --git a/src/sandbox/fork-binaries.ts b/src/sandbox/fork-binaries.ts index c92b5ef..7dfdfad 100644 --- a/src/sandbox/fork-binaries.ts +++ b/src/sandbox/fork-binaries.ts @@ -7,7 +7,7 @@ import { forkDir } from "../paths"; // release ships, alongside any matching changes in openlock that depend // on fork-side behavior. const OPENSHELL_FORK_REPO = "vessux/OpenShell"; -export const OPENSHELL_FORK_TAG = "v0.6.4"; +export const OPENSHELL_FORK_TAG = "v0.6.5"; type ForkBinary = "openshell-gateway" | "openshell-sandbox" | "openshell"; @@ -186,6 +186,12 @@ export interface CliInvocation { } export async function getCliInvocation(): Promise { + // Dev/test override: point at a specific openshell CLI binary (e.g. a + // from-source build) instead of the mise-resolved one. The mise dev install + // can lag the fork source (and on macOS dyld-fails without system z3), so + // exercising fork-side CLI behavior needs a current binary. + const override = process.env.OPENLOCK_OPENSHELL_BIN; + if (override) return { argv: [override], cwd: undefined }; if (isDevMode()) { return { argv: ["mise", "exec", "--", "openshell"], cwd: forkDir() }; } diff --git a/src/sandbox/session.test.ts b/src/sandbox/session.test.ts index 8e491e8..ff59ddd 100644 --- a/src/sandbox/session.test.ts +++ b/src/sandbox/session.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pickSessionHarness, resolveRepoPolicy, userExplicitlyPickedHarness } from "./session"; +import { + pickSessionHarness, + resolveRepoPolicy, + stageProviderSandboxFiles, + userExplicitlyPickedHarness, +} from "./session"; describe("resolveRepoPolicy", () => { function projectWith(configBody: string): string { @@ -30,6 +35,43 @@ describe("resolveRepoPolicy", () => { }); }); +describe("stageProviderSandboxFiles", () => { + function freshStaging(): string { + const tmp = mkdtempSync(join(tmpdir(), "stage-")); + const staging = join(tmp, ".openlock"); + mkdirSync(staging); + return staging; + } + + it("writes a valid file to the prefix-stripped staging-relative location", () => { + const staging = freshStaging(); + stageProviderSandboxFiles(staging, [ + { sandboxPath: "/sandbox/.openlock/claude-config/.credentials.json", content: "{}" }, + ]); + const dest = join(staging, "claude-config/.credentials.json"); + expect(existsSync(dest)).toBe(true); + expect(readFileSync(dest, "utf-8")).toBe("{}"); + }); + + it("rejects a '..' traversal path so a provider cannot escape the staging dir", () => { + const staging = freshStaging(); + expect(() => + stageProviderSandboxFiles(staging, [ + { sandboxPath: "/sandbox/.openlock/../../etc/passwd", content: "pwned" }, + ]), + ).toThrow(/must not contain '\.\.'/); + // Confirm nothing escaped: the traversal target was never written. + expect(existsSync(join(staging, "..", "..", "etc", "passwd"))).toBe(false); + }); + + it("delegates to stagingPathFor — rejects a path outside the /sandbox/.openlock/ prefix", () => { + const staging = freshStaging(); + expect(() => + stageProviderSandboxFiles(staging, [{ sandboxPath: "/etc/passwd", content: "x" }]), + ).toThrow(/under \/sandbox\/\.openlock\//); + }); +}); + describe("userExplicitlyPickedHarness", () => { it("returns false when neither cliFlag nor env is set", () => { expect(userExplicitlyPickedHarness({ cliFlag: undefined, envOpenlockHarness: undefined })).toBe( diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index c995ca8..384aedd 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -1,6 +1,6 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { dockerDaemonReachable, podmanMachineRunning, @@ -9,8 +9,9 @@ import { } from "../doctor"; import { readGlobalConfig } from "../global-config"; import { login } from "../login"; +import { PROVIDERS } from "../providers/registry"; import { resolveProvider } from "../providers/resolve"; -import type { ProviderId } from "../providers/types"; +import type { ProviderId, SandboxFile } from "../providers/types"; import { type Runtime, resolveRuntime } from "../runtime"; import { hasAnyProvider } from "../tokens"; import { validateBranchFlagAgainstWorkdir } from "./branch-validation"; @@ -47,6 +48,7 @@ import { type Mount, restageMount, stageMounts, + stagingPathFor, workdirMount, } from "./mounts"; import { resolveOpenlockFolder } from "./openlock-folder"; @@ -68,6 +70,9 @@ export interface SandboxOpts { harness?: string; provider?: string; branch?: string; + /** Detached create: create/resolve the session but do NOT attach the harness, + * so a scripted/CI caller can drive it via `openlock exec`. */ + noAttach?: boolean; } async function buildSandboxImage(openlockFolderPath: string): Promise { @@ -117,6 +122,22 @@ interface NewSession { image: string; } +// Provider-supplied files (e.g. anthropic's dummy OAuth .credentials.json) +// land under /sandbox/.openlock/. The staging dir IS the uploaded .openlock, +// so we derive the staging-relative path via stagingPathFor — the SAME +// hardened guard stageMounts uses (absolute + no '..' segments + prefix), +// which prevents a provider writing outside .openlock (a bare prefix check +// would let `/sandbox/.openlock/../../etc/foo` escape on write). Exported for +// unit-testing the traversal rejection. +export function stageProviderSandboxFiles(staging: string, files: readonly SandboxFile[]): void { + for (const f of files) { + const rel = stagingPathFor(f.sandboxPath); + const dest = join(staging, rel); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, f.content, { mode: 0o600 }); + } +} + async function createSession( projectPath: string, resolved: ResolvedRepo, @@ -157,6 +178,7 @@ async function createSession( } stageMounts(staging, mounts); + stageProviderSandboxFiles(staging, PROVIDERS[providerId].sandboxFiles(harness)); const gitconfigPath = await prepareGitIdentity(staging); console.log( @@ -174,6 +196,14 @@ async function createSession( const setupLines = [ "cd /sandbox", "[ -f .openlock/.gitconfig ] && cp .openlock/.gitconfig .gitconfig", + // Claude Code's CLAUDE_CONFIG_DIR must be writable by the sandbox user. + // The anthropic provider normally stages .credentials.json into + // .openlock/claude-config/ host-side (stageProviderSandboxFiles calls + // mkdirSync on the parent), so the dir exists before the container starts. + // This mkdir + chown runs unconditionally to: (a) normalize ownership to + // the sandbox user after host-side upload, and (b) cover harnesses or + // providers that stage no file there. `|| true` keeps it non-fatal. + "mkdir -p .openlock/claude-config && chown -R sandbox:sandbox .openlock/claude-config 2>/dev/null || true", ]; for (const bm of bundleMounts) { const bundleName = `${basename(bm.source)}.bundle`; @@ -667,6 +697,27 @@ export async function runSandbox(opts: SandboxOpts): Promise { providerId, opts.branch, ); + + if (opts.noAttach === true) { + // Detached create: the persistent container is up (the sleep-infinity + // tether), so skip attaching the harness — a scripted/CI caller drives it + // via `openlock exec -- `. resolveOrCreateSession stamped this + // CLI's pid as attachedPid; reset the meta to never-attached so (a) the dead + // pid can't trigger a false "in use by pid" rejection on PID reuse, and + // (b) classifySession returns idle-recent (lastAttachedAt: null) so the + // detached session is NOT auto-reaped while it waits to be exec'd. Keep the + // gateway alive (>=1 session) and exit cleanly: the tether + gateway client + // otherwise keep the compiled-bun event loop from draining (see openlock-to9). + const meta = findSessionByName(sessionName); + if (meta) { + updateSessionMeta(sessionsDir(), meta.id, { attachedPid: null, lastAttachedAt: null }); + } + console.log(`Session ${sessionName} created (detached, harness not attached).`); + console.log(`Run a command with: openlock exec ${sessionName} -- `); + handleGatewayShutdown(listAllSessions(sessionsDir()).length); + process.exit(0); + } + const launch: LaunchOpts = { args: resolved.args, env: buildSandboxEnv({ providerId, harness, repoConfigEnv: resolved.env }), diff --git a/src/tokens.test.ts b/src/tokens.test.ts index a135563..eb67c61 100644 --- a/src/tokens.test.ts +++ b/src/tokens.test.ts @@ -86,6 +86,24 @@ describe("writeProvider/readProvider roundtrip", () => { expect(readProvider("openrouter", path)).toBeNull(); }); + it("round-trips a record carrying an oauth2 refresh block", () => { + const record = { + type: "claude-oauth", + credentials: { ANTHROPIC_BEARER_TOKEN: "sk-ant-oat01-roundtrip" }, + created_at: "2026-06-12T00:00:00.000Z", + refresh: { + strategy: "oauth2_refresh_token" as const, + token_url: "https://platform.claude.com/v1/oauth/token", + scopes: ["user:inference", "user:profile"], + client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + refresh_token: "sk-ant-ort01-roundtrip", + access_expires_at: "2026-06-12T01:00:00.000Z", + }, + }; + writeProvider("anthropic", record, path); + expect(readProvider("anthropic", path)).toEqual(record); + }); + it("does not clobber sibling providers", () => { writeProvider( "anthropic", @@ -135,7 +153,7 @@ describe("deleteProvider", () => { }); describe("v1 -> v2 migration", () => { - it("converts legacy {token,created_at} into providers.anthropic", () => { + it("drops the legacy {token,created_at} (incompatible with OAuth) and bumps the file to v2", () => { writeFileSync( path, JSON.stringify({ token: "legacy-token", created_at: "2026-04-01T00:00:00.000Z" }), @@ -143,17 +161,14 @@ describe("v1 -> v2 migration", () => { ); const file = readCredentials(path); expect(file.version).toBe(2); - expect(file.providers.anthropic).toEqual({ - type: "claude", - credentials: { - ANTHROPIC_BEARER_TOKEN: "Bearer legacy-token", - ANTHROPIC_AUTH_TOKEN: "legacy-token", - }, - created_at: "2026-04-01T00:00:00.000Z", - }); + // The V1 setup-token bearer cannot be carried into the OAuth-subscription + // model (no refresh material, wrong prefix mode), so it is discarded. + expect(file.providers.anthropic).toBeUndefined(); + expect(file.providers).toEqual({}); // and the new shape is now on disk: const onDisk = JSON.parse(readFileSync(path, "utf-8")); expect(onDisk.version).toBe(2); + expect(onDisk.providers).toEqual({}); }); it("is idempotent", () => { diff --git a/src/tokens.ts b/src/tokens.ts index 77e99d6..d912f95 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -3,10 +3,23 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { ProviderId } from "./providers/types"; +/** Gateway-side credential-refresh material captured HOST-side at login. Lets + * the gateway mint a fresh access token from the refresh token without a new + * interactive login. Never enters the sandbox. */ +export interface ProviderRefreshMaterial { + strategy: "oauth2_refresh_token"; + token_url: string; + scopes: string[]; + client_id: string; + refresh_token: string; + access_expires_at: string; // RFC3339, seeds gateway credential expiry +} + export interface ProviderRecord { type: string; credentials: Record; created_at: string; + refresh?: ProviderRefreshMaterial; } export interface CredentialsFileV2 { @@ -28,20 +41,16 @@ function isLegacyV1(obj: Record): obj is { token: string; creat return typeof obj.token === "string" && obj.version === undefined; } -function migrateV1(legacy: { token: string; created_at?: string }): CredentialsFileV2 { - return { - version: 2, - providers: { - anthropic: { - type: "claude", - credentials: { - ANTHROPIC_BEARER_TOKEN: `Bearer ${legacy.token}`, - ANTHROPIC_AUTH_TOKEN: legacy.token, - }, - created_at: legacy.created_at ?? new Date().toISOString(), - }, - }, - }; +// The legacy V1 file held a single long-lived `setup-token` bearer (the old +// API/inference auth mode). The anthropic provider is now OAuth-subscription: +// it stores a RAW access token (the gateway adds "Bearer " via value_prefix) +// plus refresh material that a V1 token simply does not have. Carrying the V1 +// token forward would produce a double-prefixed, unrefreshable, wrong-mode +// credential — so we drop it and surface an empty file, prompting a fresh +// `openlock login` through the new OAuth flow. We still bump the file to V2 on +// disk so the stale single-token shape stops being re-parsed every read. +function migrateV1(_legacy: { token: string; created_at?: string }): CredentialsFileV2 { + return emptyFile(); } function writeAtomic(path: string, data: CredentialsFileV2): void {