From bc78ae8e5f0e0f3db99807a1ecca94a05365b2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 20:11:49 +0200 Subject: [PATCH 01/24] feat(policy): support cred_inject value_prefix; drop opencode+anthropic path --- policies/default.yaml | 19 +---------- policies/wire-proof-local.yaml | 4 +-- src/config-core/policy/schema.test.ts | 46 +++++++++++++++++++++++++++ src/config-core/policy/schema.ts | 3 +- src/config-core/policy/types.ts | 1 + src/providers/types.ts | 2 +- 6 files changed, 53 insertions(+), 22 deletions(-) diff --git a/policies/default.yaml b/policies/default.yaml index 7e71700..ffca5eb 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..fe04c9f 100644 --- a/policies/wire-proof-local.yaml +++ b/policies/wire-proof-local.yaml @@ -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/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/providers/types.ts b/src/providers/types.ts index 2b031b8..f204c33 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -17,7 +17,7 @@ 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 PolicyEndpointSpec { From 3f5bc53b867d163c1879af0cd6115fc400b4d943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 20:19:02 +0200 Subject: [PATCH 02/24] feat(anthropic): host-side OAuth PKCE /login client --- src/providers/anthropic-oauth.test.ts | 193 ++++++++++++++++++++++++++ src/providers/anthropic-oauth.ts | 161 +++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 src/providers/anthropic-oauth.test.ts create mode 100644 src/providers/anthropic-oauth.ts diff --git a/src/providers/anthropic-oauth.test.ts b/src/providers/anthropic-oauth.test.ts new file mode 100644 index 0000000..2583119 --- /dev/null +++ b/src/providers/anthropic-oauth.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "bun:test"; +import { + buildAuthorizeUrl, + buildPkce, + CLAUDE_OAUTH_SCOPES, + exchangeCode, + type OAuthTokens, + runLogin, +} from "./anthropic-oauth"; +import type { LoginIO } from "./types"; + +const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; +const BASE64URL = /^[A-Za-z0-9_-]+$/; + +/** A LoginIO whose readLine returns whatever `reply(printed)` computes from the + * concatenated stdout written so far. Lets a test echo back the state it sees + * in the printed authorize URL, enabling a deterministic happy path. */ +function makeIO(reply: (printed: string) => string): { + io: LoginIO; + stdout: () => string; +} { + const out: string[] = []; + const err: string[] = []; + const io: LoginIO = { + readLine: async () => reply(out.join("")), + writeStdout: (s) => out.push(s), + writeStderr: (s) => err.push(s), + isTTY: false, + }; + return { io, stdout: () => out.join("") + err.join("") }; +} + +/** Pull the `state` query param out of a printed authorize URL. */ +function extractState(printed: string): string { + const match = printed.match(/https:\/\/\S+/); + if (!match) throw new Error("no URL printed"); + const url = new URL(match[0]); + const state = url.searchParams.get("state"); + if (!state) throw new Error("no state in printed URL"); + return state; +} + +describe("buildPkce", () => { + it("produces a base64url verifier of length >= 43", () => { + const { verifier } = buildPkce(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier).toMatch(BASE64URL); + expect(verifier).not.toContain("="); + }); + + it("produces an S256 challenge that is base64url and differs from the verifier", () => { + const { verifier, challenge } = buildPkce(); + expect(challenge).toMatch(BASE64URL); + expect(challenge).not.toContain("="); + expect(challenge).not.toBe(verifier); + }); + + it("produces unique verifiers across calls", () => { + expect(buildPkce().verifier).not.toBe(buildPkce().verifier); + }); +}); + +describe("buildAuthorizeUrl", () => { + it("emits the expected PKCE / hosted-callback query params", () => { + const url = new URL(buildAuthorizeUrl({ challenge: "CHALLENGE", state: "STATE" })); + expect(`${url.origin}${url.pathname}`).toBe("https://claude.ai/oauth/authorize"); + const p = url.searchParams; + expect(p.get("response_type")).toBe("code"); + expect(p.get("client_id")).toBeTruthy(); + expect(p.get("redirect_uri")).toBe(REDIRECT_URI); + expect(p.get("scope")).toBe(CLAUDE_OAUTH_SCOPES.join(" ")); + expect(p.get("code_challenge")).toBe("CHALLENGE"); + expect(p.get("code_challenge_method")).toBe("S256"); + expect(p.get("state")).toBe("STATE"); + // hosted-callback display flow + expect(p.get("code")).toBe("true"); + }); +}); + +describe("exchangeCode", () => { + it("POSTs a JSON authorization_code body and maps the token response", async () => { + let captured: { url: string; init: RequestInit } | undefined; + const fakeFetch = (async (url: string | URL | Request, init?: RequestInit) => { + captured = { url: String(url), init: init ?? {} }; + return new Response( + JSON.stringify({ + access_token: "sk-ant-oat01-A", + refresh_token: "sk-ant-ort01-R", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const tokens = await exchangeCode( + { code: "THECODE", state: "THESTATE", verifier: "THEVERIFIER" }, + fakeFetch, + ); + + expect(captured).toBeDefined(); + expect(captured?.url).toBe("https://platform.claude.com/v1/oauth/token"); + const headers = new Headers(captured?.init.headers); + expect(headers.get("content-type")).toContain("application/json"); + expect(headers.get("accept")).toContain("application/json"); + + const body = JSON.parse(String(captured?.init.body)); + expect(body.grant_type).toBe("authorization_code"); + expect(body.client_id).toBeTruthy(); + expect(body.code).toBe("THECODE"); + expect(body.state).toBe("THESTATE"); + expect(body.code_verifier).toBe("THEVERIFIER"); + expect(body.redirect_uri).toBe(REDIRECT_URI); + + const t: OAuthTokens = tokens; + expect(t.access_token).toBe("sk-ant-oat01-A"); + expect(t.refresh_token).toBe("sk-ant-ort01-R"); + expect(t.client_id).toBeTruthy(); + expect(typeof t.expires_at).toBe("string"); + expect(Number.isNaN(Date.parse(t.expires_at))).toBe(false); + }); + + it("throws including status and body on non-OK", async () => { + const fakeFetch = (async () => + new Response("nope", { status: 400 })) as unknown as typeof fetch; + await expect(exchangeCode({ code: "c", state: "s", verifier: "v" }, fakeFetch)).rejects.toThrow( + /400/, + ); + }); + + it("resolves with a valid expires_at when expires_in is absent", async () => { + const fakeFetch = (async () => + new Response( + JSON.stringify({ access_token: "sk-ant-oat01-A", refresh_token: "sk-ant-ort01-R" }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch; + const r = await exchangeCode({ code: "c", state: "s", verifier: "v" }, fakeFetch); + expect(typeof r.expires_at).toBe("string"); + expect(Number.isNaN(Date.parse(r.expires_at))).toBe(false); + }); + + it("rejects when access_token is absent", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ refresh_token: "sk-ant-ort01-R", expires_in: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + await expect(exchangeCode({ code: "c", state: "s", verifier: "v" }, fakeFetch)).rejects.toThrow( + /missing access_token or refresh_token/, + ); + }); +}); + +describe("runLogin", () => { + const noopOpener = () => {}; + + it("rejects when the pasted state does not match the generated state (CSRF)", async () => { + const { io } = makeIO(() => "SOMECODE#WRONGSTATE"); + const fakeFetch = (async () => { + throw new Error("must not reach token exchange on state mismatch"); + }) as unknown as typeof fetch; + await expect(runLogin(io, { doFetch: fakeFetch, openUrl: noopOpener })).rejects.toThrow( + /state/i, + ); + }); + + it("happy path: echoes the printed state back and returns mapped tokens", async () => { + let exchanged: Record | undefined; + const fakeFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + exchanged = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + access_token: "sk-ant-oat01-OK", + refresh_token: "sk-ant-ort01-OK", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + // The stub reads the state out of the printed authorize URL and pastes + // back `code#state` so the CSRF check passes deterministically. + const { io } = makeIO((printed) => `THECODE#${extractState(printed)}`); + + const tokens = await runLogin(io, { doFetch: fakeFetch, openUrl: noopOpener }); + + expect(tokens.access_token).toBe("sk-ant-oat01-OK"); + expect(tokens.refresh_token).toBe("sk-ant-ort01-OK"); + expect(exchanged?.code).toBe("THECODE"); + expect(exchanged?.grant_type).toBe("authorization_code"); + // verifier sent to the token endpoint is the one minted internally + expect(exchanged?.code_verifier).toBeTruthy(); + }); +}); diff --git a/src/providers/anthropic-oauth.ts b/src/providers/anthropic-oauth.ts new file mode 100644 index 0000000..a6d8dc7 --- /dev/null +++ b/src/providers/anthropic-oauth.ts @@ -0,0 +1,161 @@ +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "bun"; +import type { LoginIO } from "./types"; + +// OAuth client constants. Per design decision D5 these live in source, not in +// docs/fixtures/policies. The token endpoint is used here for the +// authorization-code exchange and later (gateway-side) for refresh. +const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CLAUDE_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; // max/subscription mode +const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +// HOSTED redirect: after consent the page displays a `code#state` string the +// user pastes back. There is no localhost server — this is a fixed constant. +const CLAUDE_OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; +export const CLAUDE_OAUTH_SCOPES = [ + "org:create_api_key", + "user:profile", + "user:inference", + "user:sessions:claude_code", + "user:mcp_servers", + "user:file_upload", +] as const; + +/** Real subscription access+refresh token pair captured HOST-side. Never enters + * the sandbox. */ +export interface OAuthTokens { + access_token: string; + refresh_token: string; + /** RFC3339 timestamp at which `access_token` expires. */ + expires_at: string; + client_id: string; +} + +function base64url(buf: Buffer): string { + return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +/** PKCE pair: a high-entropy `verifier` and its S256 `challenge`. */ +export function buildPkce(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +/** Build the hosted-callback authorize URL. No `redirect_uri` parameter is + * accepted — the redirect is the fixed hosted constant. */ +export function buildAuthorizeUrl(a: { challenge: string; state: string }): string { + const url = new URL(CLAUDE_OAUTH_AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLAUDE_OAUTH_CLIENT_ID); + url.searchParams.set("redirect_uri", CLAUDE_OAUTH_REDIRECT_URI); + url.searchParams.set("scope", CLAUDE_OAUTH_SCOPES.join(" ")); + url.searchParams.set("code_challenge", a.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", a.state); + // Required for the hosted-callback display flow (renders code#state for paste-back). + url.searchParams.set("code", "true"); + return url.toString(); +} + +/** Exchange an authorization code for tokens against the Claude token endpoint. */ +export async function exchangeCode( + a: { code: string; state: string; verifier: string }, + doFetch: typeof fetch = fetch, +): Promise { + const res = await doFetch(CLAUDE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: CLAUDE_OAUTH_CLIENT_ID, + code: a.code, + state: a.state, + redirect_uri: CLAUDE_OAUTH_REDIRECT_URI, + code_verifier: a.verifier, + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`OAuth token exchange failed: ${res.status} ${res.statusText} ${text}`.trim()); + } + const json = (await res.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number | string; + }; + if (!json.access_token || !json.refresh_token) { + throw new Error( + "OAuth token exchange returned an incomplete response (missing access_token or refresh_token)", + ); + } + // Fall back to 3600 s when expires_in is absent or non-numeric. A short + // fallback causes an early gateway refresh (harmless — refresh_token is + // present). An over-long expiry would silently break inference once the + // access_token actually expires, so erring short is always the safer choice. + const ttl = + Number.isFinite(Number(json.expires_in)) && Number(json.expires_in) > 0 + ? Number(json.expires_in) + : 3600; + return { + access_token: json.access_token, + refresh_token: json.refresh_token, + expires_at: new Date(Date.now() + ttl * 1000).toISOString(), + client_id: CLAUDE_OAUTH_CLIENT_ID, + }; +} + +/** Best-effort browser open on macOS. Never throws. */ +function defaultOpenUrl(url: string): void { + try { + const proc = spawn({ cmd: ["open", url], stdout: "ignore", stderr: "ignore" }); + proc.unref(); + } catch { + // `open` unavailable (non-macOS, sandbox, etc.) — printing the URL is enough. + } +} + +export interface RunLoginOptions { + doFetch?: typeof fetch; + openUrl?: (url: string) => void; +} + +/** Interactive paste-back driver. Public signature the provider calls is + * `runLogin(io)`; `opts` exists only so tests can inject fetch/opener. */ +export async function runLogin(io: LoginIO, opts: RunLoginOptions = {}): Promise { + const doFetch = opts.doFetch ?? fetch; + const openUrl = opts.openUrl ?? defaultOpenUrl; + + const { verifier, challenge } = buildPkce(); + const state = base64url(randomBytes(16)); + const authorizeUrl = buildAuthorizeUrl({ challenge, state }); + + io.writeStdout( + "To authorize openlock with your Claude subscription, open this URL in your browser:\n\n", + ); + io.writeStdout(`${authorizeUrl}\n\n`); + io.writeStdout( + "After approving, the page shows a code (format: code#state). Copy it and paste it below.\n", + ); + openUrl(authorizeUrl); + + const pasted = (await io.readLine("Paste the code shown after authorizing:\n> ")).trim(); + if (!pasted) throw new Error("No authorization code entered."); + + const hashIdx = pasted.indexOf("#"); + if (hashIdx === -1) { + throw new Error( + "Pasted value is not in the expected `code#state` form — copy the full string the callback page displays.", + ); + } + const code = pasted.slice(0, hashIdx).trim(); + const returnedState = pasted.slice(hashIdx + 1).trim(); + if (returnedState !== state) { + throw new Error("OAuth state mismatch (possible CSRF) — aborting login."); + } + if (!code) throw new Error("No authorization code found in pasted value."); + + return exchangeCode({ code, state, verifier }, doFetch); +} From 8a9dff299ed257eee0cbce6cebed06c460f19fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 20:30:05 +0200 Subject: [PATCH 03/24] feat(sandbox): provider sandboxFiles delivery + CLAUDE_CONFIG_DIR --- src/providers/anthropic.ts | 4 +++ src/providers/openrouter.ts | 2 ++ src/providers/registry.test.ts | 11 ++++++++ src/providers/types.test.ts | 3 +++ src/providers/types.ts | 10 ++++++++ src/sandbox/container.test.ts | 18 +++++++++++++ src/sandbox/container.ts | 7 +++++- src/sandbox/session.test.ts | 46 ++++++++++++++++++++++++++++++++-- src/sandbox/session.ts | 33 +++++++++++++++++++++--- 9 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 91a9f81..e8d9a55 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -70,6 +70,10 @@ export const ANTHROPIC: ProviderPlugin = { return { ANTHROPIC_API_KEY: "managed-by-openlock-do-not-leak" }; }, + // TEMPORARY: real impl (dummy OAuth-shaped .credentials.json that flips + // Claude Code into OAuth mode) lands in Phase 5. See bd openlock-ndb. + sandboxFiles: () => [], + redactionPatterns(): readonly RegExp[] { return [ /sk-ant-oat[0-9]{2}-[a-zA-Z0-9_-]{20,}/g, diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index 611d109..4fe28c9 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -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/types.test.ts b/src/providers/types.test.ts index 0a4c796..676a6ee 100644 --- a/src/providers/types.test.ts +++ b/src/providers/types.test.ts @@ -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 f204c33..bd23d38 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -20,6 +20,12 @@ interface CredInjectSpec { 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 { host: string; port: number; @@ -42,5 +48,9 @@ export interface ProviderPlugin { 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/container.test.ts b/src/sandbox/container.test.ts index 00593b8..5065e74 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -340,6 +340,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..ee94597 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( 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..46f6f2a 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"; @@ -117,6 +119,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 +175,7 @@ async function createSession( } stageMounts(staging, mounts); + stageProviderSandboxFiles(staging, PROVIDERS[providerId].sandboxFiles(harness)); const gitconfigPath = await prepareGitIdentity(staging); console.log( @@ -174,6 +193,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. + // We could not runtime-verify the writability requirement without a real + // account, so provision + chown defensively; `|| true` keeps it + // non-fatal for harnesses that don't use it. NOTE: this `mkdir` is + // currently load-bearing — while anthropic's sandboxFiles() returns [] + // (the Phase 4 interim stub), this is the ONLY thing creating the dir. + // Phase 5 will also stage the dir host-side; keep this unconditional. + "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`; From 135d3823ee8373f662cde0fb9103043b59e98703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 20:45:25 +0200 Subject: [PATCH 04/24] feat(anthropic): replace setup-token with OAuth subscription provider --- src/login.ts | 5 +- src/providers/anthropic-oauth.ts | 2 +- src/providers/anthropic.test.ts | 58 +++++++------- src/providers/anthropic.ts | 125 ++++++++++++++++++------------- src/providers/openrouter.test.ts | 16 ++-- src/providers/openrouter.ts | 6 +- src/providers/resolve.test.ts | 13 +++- src/providers/resolve.ts | 10 ++- src/providers/types.test.ts | 2 +- src/providers/types.ts | 13 +++- src/sandbox/container.test.ts | 10 ++- src/sandbox/session.ts | 12 +-- src/tokens.test.ts | 33 +++++--- src/tokens.ts | 37 +++++---- 14 files changed, 211 insertions(+), 131 deletions(-) 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-oauth.ts b/src/providers/anthropic-oauth.ts index a6d8dc7..e1017a8 100644 --- a/src/providers/anthropic-oauth.ts +++ b/src/providers/anthropic-oauth.ts @@ -7,7 +7,7 @@ import type { LoginIO } from "./types"; // authorization-code exchange and later (gateway-side) for refresh. const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const CLAUDE_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; // max/subscription mode -const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +export const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; // HOSTED redirect: after consent the page displays a `code#state` string the // user pastes back. There is no localhost server — this is a fixed constant. const CLAUDE_OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; 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 e8d9a55..9a72ada 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,84 +1,101 @@ -import { spawn } from "bun"; import type { Harness } from "../sandbox/harness"; -import type { LoginIO, PolicyEndpointSpec, ProviderCredentials, ProviderPlugin } from "./types"; +import { CLAUDE_OAUTH_SCOPES, CLAUDE_OAUTH_TOKEN_URL, runLogin } from "./anthropic-oauth"; +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."); + async loginInteractive(io: LoginIO): Promise { + const t = await runLogin(io); return { - ANTHROPIC_BEARER_TOKEN: `Bearer ${token}`, - ANTHROPIC_AUTH_TOKEN: token, + // RAW access token — NO "Bearer " prefix. The gateway prepends "Bearer " + // via the policy cred_inject value_prefix at egress. + credentials: { ANTHROPIC_BEARER_TOKEN: t.access_token }, + refresh: { + strategy: "oauth2_refresh_token", + token_url: CLAUDE_OAUTH_TOKEN_URL, + scopes: [...CLAUDE_OAUTH_SCOPES], + client_id: t.client_id, + refresh_token: t.refresh_token, + access_expires_at: t.expires_at, + }, }; }, - 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 {}; }, - // TEMPORARY: real impl (dummy OAuth-shaped .credentials.json that flips - // Claude Code into OAuth mode) lands in Phase 5. See bd openlock-ndb. - sandboxFiles: () => [], + 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 4fe28c9..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[] { 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 676a6ee..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 []; diff --git a/src/providers/types.ts b/src/providers/types.ts index bd23d38..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; @@ -44,7 +53,7 @@ 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; diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index 5065e74..5cbc74c 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -323,13 +323,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", () => { diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index 46f6f2a..c69ddbf 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -194,12 +194,12 @@ async function createSession( "cd /sandbox", "[ -f .openlock/.gitconfig ] && cp .openlock/.gitconfig .gitconfig", // Claude Code's CLAUDE_CONFIG_DIR must be writable by the sandbox user. - // We could not runtime-verify the writability requirement without a real - // account, so provision + chown defensively; `|| true` keeps it - // non-fatal for harnesses that don't use it. NOTE: this `mkdir` is - // currently load-bearing — while anthropic's sandboxFiles() returns [] - // (the Phase 4 interim stub), this is the ONLY thing creating the dir. - // Phase 5 will also stage the dir host-side; keep this unconditional. + // 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) { 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 { From 6ec03efc5191c25765d03409af9a1afbdd393afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 20:58:46 +0200 Subject: [PATCH 05/24] feat(ensure-provider): import OAuth profile, seed-once, configure gateway refresh --- src/sandbox/claude-oauth-profile.test.ts | 46 ++++++++ src/sandbox/claude-oauth-profile.ts | 50 ++++++++ src/sandbox/ensure-provider.test.ts | 101 ++++++++++++++++ src/sandbox/ensure-provider.ts | 143 +++++++++++++++++++++-- 4 files changed, 330 insertions(+), 10 deletions(-) create mode 100644 src/sandbox/claude-oauth-profile.test.ts create mode 100644 src/sandbox/claude-oauth-profile.ts diff --git a/src/sandbox/claude-oauth-profile.test.ts b/src/sandbox/claude-oauth-profile.test.ts new file mode 100644 index 0000000..9b393ab --- /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://console.anthropic.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..21a8746 --- /dev/null +++ b/src/sandbox/claude-oauth-profile.ts @@ -0,0 +1,50 @@ +import yaml from "js-yaml"; +import type { ProviderRefreshMaterial } from "../tokens"; + +/** + * Build the openshell runtime provider-profile YAML for the Claude OAuth + * subscription provider. Importing this profile (idempotently) 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", + 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/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index 73f127f..9ed708b 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -97,4 +97,105 @@ 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://console.anthropic.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"] }); + 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(); + // idempotent profile import still runs. + expect(verb(m.calls, "profile", "import")).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://console.anthropic.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..15658b2 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 } 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,111 @@ 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`); + } + + const dir = mkdtempSync(join(tmpdir(), "olk-prof-")); + try { + const profPath = join(dir, "claude-oauth.yaml"); + writeFileSync(profPath, buildClaudeOAuthProfileYaml(refresh)); + // `provider profile import` is idempotent, so we run it on every ensure + // regardless of whether the provider already exists. It awaits via mustOk, + // so it has fully completed before the finally removes the temp 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 +177,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 +201,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); } From a0ab6a1a85b67442c6ffc282988469fc4576b0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 21:10:32 +0200 Subject: [PATCH 06/24] chore: render value_prefix into default policy + document it --- docs/agent-config-reference.md | 2 +- policies/default.yaml | 2 +- scripts/render-default-policies.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) 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 ffca5eb..ed4e717 100644 --- a/policies/default.yaml +++ b/policies/default.yaml @@ -47,7 +47,7 @@ network_policies: inject: - header: Authorization from_credential: ANTHROPIC_BEARER_TOKEN - value_prefix: "Bearer " + value_prefix: 'Bearer ' allowed_secrets: - ANTHROPIC_BEARER_TOKEN opencode: 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 } : {}), })), }, } From 716a73bdaffb985091b32b3b6f20a45974021453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 21:16:55 +0200 Subject: [PATCH 07/24] chore: fix stale wire-proof comment + align test fixture token_url --- policies/wire-proof-local.yaml | 2 +- src/sandbox/claude-oauth-profile.test.ts | 2 +- src/sandbox/ensure-provider.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/policies/wire-proof-local.yaml b/policies/wire-proof-local.yaml index fe04c9f..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 # diff --git a/src/sandbox/claude-oauth-profile.test.ts b/src/sandbox/claude-oauth-profile.test.ts index 9b393ab..18cd3d4 100644 --- a/src/sandbox/claude-oauth-profile.test.ts +++ b/src/sandbox/claude-oauth-profile.test.ts @@ -5,7 +5,7 @@ import { buildClaudeOAuthProfileYaml } from "./claude-oauth-profile"; const material: ProviderRefreshMaterial = { strategy: "oauth2_refresh_token", - token_url: "https://console.anthropic.com/v1/oauth/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", diff --git a/src/sandbox/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index 9ed708b..b9d7378 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -106,7 +106,7 @@ describe("_ensureProviderForTests", () => { created_at: "t", refresh: { strategy: "oauth2_refresh_token", - token_url: "https://console.anthropic.com/v1/oauth/token", + token_url: "https://platform.claude.com/v1/oauth/token", scopes: ["user:inference"], client_id: "client-abc", refresh_token: "rt-secret", @@ -183,7 +183,7 @@ describe("_ensureProviderForTests", () => { created_at: "t", refresh: { strategy: "oauth2_refresh_token", - token_url: "https://console.anthropic.com/v1/oauth/token", + token_url: "https://platform.claude.com/v1/oauth/token", scopes: ["user:inference"], client_id: "client-abc", refresh_token: "rt-secret", From 4ba4e4cbe7ab448537a34c5b5440c5dd61e26e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Fri, 12 Jun 2026 22:34:46 +0200 Subject: [PATCH 08/24] chore(sandbox): bump OPENSHELL_FORK_TAG to v0.6.5 (value_prefix) --- src/sandbox/fork-binaries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sandbox/fork-binaries.ts b/src/sandbox/fork-binaries.ts index c92b5ef..44fdae3 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"; From 7c140cd12ed9a7dd84e681a545be676ea21cd116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 07:19:17 +0200 Subject: [PATCH 09/24] fix(anthropic): use console.anthropic.com OAuth endpoints + canonical 3-scope set --- src/providers/anthropic-oauth.test.ts | 14 ++++++++++++-- src/providers/anthropic-oauth.ts | 11 ++++++----- src/sandbox/claude-oauth-profile.test.ts | 2 +- src/sandbox/ensure-provider.test.ts | 4 ++-- src/tokens.test.ts | 2 +- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/providers/anthropic-oauth.test.ts b/src/providers/anthropic-oauth.test.ts index 2583119..575f1f1 100644 --- a/src/providers/anthropic-oauth.test.ts +++ b/src/providers/anthropic-oauth.test.ts @@ -9,7 +9,7 @@ import { } from "./anthropic-oauth"; import type { LoginIO } from "./types"; -const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; +const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; const BASE64URL = /^[A-Za-z0-9_-]+$/; /** A LoginIO whose readLine returns whatever `reply(printed)` computes from the @@ -61,6 +61,16 @@ describe("buildPkce", () => { }); describe("buildAuthorizeUrl", () => { + it("uses the canonical Claude Code OAuth scope set (verified against the live flow)", () => { + // The claude.ai subscription flow registers exactly these 3 scopes for this + // client; extra scopes are rejected by the authorize endpoint. + expect([...CLAUDE_OAUTH_SCOPES]).toEqual([ + "org:create_api_key", + "user:profile", + "user:inference", + ]); + }); + it("emits the expected PKCE / hosted-callback query params", () => { const url = new URL(buildAuthorizeUrl({ challenge: "CHALLENGE", state: "STATE" })); expect(`${url.origin}${url.pathname}`).toBe("https://claude.ai/oauth/authorize"); @@ -98,7 +108,7 @@ describe("exchangeCode", () => { ); expect(captured).toBeDefined(); - expect(captured?.url).toBe("https://platform.claude.com/v1/oauth/token"); + expect(captured?.url).toBe("https://console.anthropic.com/v1/oauth/token"); const headers = new Headers(captured?.init.headers); expect(headers.get("content-type")).toContain("application/json"); expect(headers.get("accept")).toContain("application/json"); diff --git a/src/providers/anthropic-oauth.ts b/src/providers/anthropic-oauth.ts index e1017a8..9b71cd2 100644 --- a/src/providers/anthropic-oauth.ts +++ b/src/providers/anthropic-oauth.ts @@ -5,19 +5,20 @@ import type { LoginIO } from "./types"; // OAuth client constants. Per design decision D5 these live in source, not in // docs/fixtures/policies. The token endpoint is used here for the // authorization-code exchange and later (gateway-side) for refresh. +// Values match the canonical Claude Code subscription OAuth flow — verified +// against a live login (the `console.anthropic.com` callback + token host and +// the 3-scope set are what client 9d1c250a is registered for; `platform.claude.com` +// and extra scopes are rejected by the authorize endpoint). const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const CLAUDE_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; // max/subscription mode -export const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +export const CLAUDE_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; // HOSTED redirect: after consent the page displays a `code#state` string the // user pastes back. There is no localhost server — this is a fixed constant. -const CLAUDE_OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; +const CLAUDE_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; export const CLAUDE_OAUTH_SCOPES = [ "org:create_api_key", "user:profile", "user:inference", - "user:sessions:claude_code", - "user:mcp_servers", - "user:file_upload", ] as const; /** Real subscription access+refresh token pair captured HOST-side. Never enters diff --git a/src/sandbox/claude-oauth-profile.test.ts b/src/sandbox/claude-oauth-profile.test.ts index 18cd3d4..9b393ab 100644 --- a/src/sandbox/claude-oauth-profile.test.ts +++ b/src/sandbox/claude-oauth-profile.test.ts @@ -5,7 +5,7 @@ import { buildClaudeOAuthProfileYaml } from "./claude-oauth-profile"; const material: ProviderRefreshMaterial = { strategy: "oauth2_refresh_token", - token_url: "https://platform.claude.com/v1/oauth/token", + token_url: "https://console.anthropic.com/v1/oauth/token", scopes: ["org:create_api_key", "user:profile", "user:inference"], client_id: "client-abc", refresh_token: "rt-secret", diff --git a/src/sandbox/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index b9d7378..9ed708b 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -106,7 +106,7 @@ describe("_ensureProviderForTests", () => { created_at: "t", refresh: { strategy: "oauth2_refresh_token", - token_url: "https://platform.claude.com/v1/oauth/token", + token_url: "https://console.anthropic.com/v1/oauth/token", scopes: ["user:inference"], client_id: "client-abc", refresh_token: "rt-secret", @@ -183,7 +183,7 @@ describe("_ensureProviderForTests", () => { created_at: "t", refresh: { strategy: "oauth2_refresh_token", - token_url: "https://platform.claude.com/v1/oauth/token", + token_url: "https://console.anthropic.com/v1/oauth/token", scopes: ["user:inference"], client_id: "client-abc", refresh_token: "rt-secret", diff --git a/src/tokens.test.ts b/src/tokens.test.ts index eb67c61..4b1fd52 100644 --- a/src/tokens.test.ts +++ b/src/tokens.test.ts @@ -93,7 +93,7 @@ describe("writeProvider/readProvider 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", + token_url: "https://console.anthropic.com/v1/oauth/token", scopes: ["user:inference", "user:profile"], client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", refresh_token: "sk-ant-ort01-roundtrip", From fd4e96fbfa9c9b2d661d2218eba766fb99499a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 08:41:37 +0200 Subject: [PATCH 10/24] fix(anthropic): match Claude Code subscription OAuth exactly (claude.com/cai authorize, platform.claude.com redirect/token, CLAUDE_AI scopes) --- src/providers/anthropic-oauth.test.ts | 16 +++++++++------- src/providers/anthropic-oauth.ts | 24 ++++++++++++++---------- src/sandbox/claude-oauth-profile.test.ts | 2 +- src/sandbox/ensure-provider.test.ts | 4 ++-- src/tokens.test.ts | 2 +- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/providers/anthropic-oauth.test.ts b/src/providers/anthropic-oauth.test.ts index 575f1f1..572727c 100644 --- a/src/providers/anthropic-oauth.test.ts +++ b/src/providers/anthropic-oauth.test.ts @@ -9,7 +9,7 @@ import { } from "./anthropic-oauth"; import type { LoginIO } from "./types"; -const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; +const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; const BASE64URL = /^[A-Za-z0-9_-]+$/; /** A LoginIO whose readLine returns whatever `reply(printed)` computes from the @@ -61,19 +61,21 @@ describe("buildPkce", () => { }); describe("buildAuthorizeUrl", () => { - it("uses the canonical Claude Code OAuth scope set (verified against the live flow)", () => { - // The claude.ai subscription flow registers exactly these 3 scopes for this - // client; extra scopes are rejected by the authorize endpoint. + it("uses the Claude Code subscription scope set (extracted from the CC bundle)", () => { + // CLAUDE_AI_OAUTH_SCOPES from the Claude Code bundle — the subscription set. + // No org:create_api_key (console/API mode only). expect([...CLAUDE_OAUTH_SCOPES]).toEqual([ - "org:create_api_key", "user:profile", "user:inference", + "user:sessions:claude_code", + "user:mcp_servers", + "user:file_upload", ]); }); it("emits the expected PKCE / hosted-callback query params", () => { const url = new URL(buildAuthorizeUrl({ challenge: "CHALLENGE", state: "STATE" })); - expect(`${url.origin}${url.pathname}`).toBe("https://claude.ai/oauth/authorize"); + expect(`${url.origin}${url.pathname}`).toBe("https://claude.com/cai/oauth/authorize"); const p = url.searchParams; expect(p.get("response_type")).toBe("code"); expect(p.get("client_id")).toBeTruthy(); @@ -108,7 +110,7 @@ describe("exchangeCode", () => { ); expect(captured).toBeDefined(); - expect(captured?.url).toBe("https://console.anthropic.com/v1/oauth/token"); + expect(captured?.url).toBe("https://platform.claude.com/v1/oauth/token"); const headers = new Headers(captured?.init.headers); expect(headers.get("content-type")).toContain("application/json"); expect(headers.get("accept")).toContain("application/json"); diff --git a/src/providers/anthropic-oauth.ts b/src/providers/anthropic-oauth.ts index 9b71cd2..e77a5da 100644 --- a/src/providers/anthropic-oauth.ts +++ b/src/providers/anthropic-oauth.ts @@ -3,22 +3,26 @@ import { spawn } from "bun"; import type { LoginIO } from "./types"; // OAuth client constants. Per design decision D5 these live in source, not in -// docs/fixtures/policies. The token endpoint is used here for the -// authorization-code exchange and later (gateway-side) for refresh. -// Values match the canonical Claude Code subscription OAuth flow — verified -// against a live login (the `console.anthropic.com` callback + token host and -// the 3-scope set are what client 9d1c250a is registered for; `platform.claude.com` -// and extra scopes are rejected by the authorize endpoint). +// docs/fixtures/policies. Values are extracted verbatim from the Claude Code CLI +// bundle's config object (v2.1.176) — the authoritative source. The CLAUDE_AI +// (subscription) flow authorizes at claude.com/cai; redirect + token live on +// platform.claude.com (a single shared pair for both console and subscription +// modes). Do NOT substitute claude.ai/oauth/authorize or console.anthropic.com — +// those are a different mode / stale and are rejected for this client. const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const CLAUDE_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; // max/subscription mode -export const CLAUDE_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; +const CLAUDE_OAUTH_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize"; // CLAUDE_AI subscription mode +export const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; // HOSTED redirect: after consent the page displays a `code#state` string the // user pastes back. There is no localhost server — this is a fixed constant. -const CLAUDE_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; +const CLAUDE_OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; +// CLAUDE_AI_OAUTH_SCOPES from the bundle — the subscription scope set. Note this +// omits `org:create_api_key` (that scope belongs to the console/API mode only). export const CLAUDE_OAUTH_SCOPES = [ - "org:create_api_key", "user:profile", "user:inference", + "user:sessions:claude_code", + "user:mcp_servers", + "user:file_upload", ] as const; /** Real subscription access+refresh token pair captured HOST-side. Never enters diff --git a/src/sandbox/claude-oauth-profile.test.ts b/src/sandbox/claude-oauth-profile.test.ts index 9b393ab..18cd3d4 100644 --- a/src/sandbox/claude-oauth-profile.test.ts +++ b/src/sandbox/claude-oauth-profile.test.ts @@ -5,7 +5,7 @@ import { buildClaudeOAuthProfileYaml } from "./claude-oauth-profile"; const material: ProviderRefreshMaterial = { strategy: "oauth2_refresh_token", - token_url: "https://console.anthropic.com/v1/oauth/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", diff --git a/src/sandbox/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index 9ed708b..b9d7378 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -106,7 +106,7 @@ describe("_ensureProviderForTests", () => { created_at: "t", refresh: { strategy: "oauth2_refresh_token", - token_url: "https://console.anthropic.com/v1/oauth/token", + token_url: "https://platform.claude.com/v1/oauth/token", scopes: ["user:inference"], client_id: "client-abc", refresh_token: "rt-secret", @@ -183,7 +183,7 @@ describe("_ensureProviderForTests", () => { created_at: "t", refresh: { strategy: "oauth2_refresh_token", - token_url: "https://console.anthropic.com/v1/oauth/token", + token_url: "https://platform.claude.com/v1/oauth/token", scopes: ["user:inference"], client_id: "client-abc", refresh_token: "rt-secret", diff --git a/src/tokens.test.ts b/src/tokens.test.ts index 4b1fd52..eb67c61 100644 --- a/src/tokens.test.ts +++ b/src/tokens.test.ts @@ -93,7 +93,7 @@ describe("writeProvider/readProvider roundtrip", () => { created_at: "2026-06-12T00:00:00.000Z", refresh: { strategy: "oauth2_refresh_token" as const, - token_url: "https://console.anthropic.com/v1/oauth/token", + 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", From fa60c81e3c3c5c57a1cbef81b6ded69dbae7f8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 19:09:26 +0200 Subject: [PATCH 11/24] feat(sandbox): OPENLOCK_OPENSHELL_BIN dev override for the openshell CLI --- src/sandbox/fork-binaries.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/sandbox/fork-binaries.ts b/src/sandbox/fork-binaries.ts index 44fdae3..7dfdfad 100644 --- a/src/sandbox/fork-binaries.ts +++ b/src/sandbox/fork-binaries.ts @@ -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() }; } From 4b2defb196fa7cd6a563d04ef2f54c50c8a6f25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 19:54:04 +0200 Subject: [PATCH 12/24] =?UTF-8?q?feat(anthropic):=20parseClaudeOauthBlob?= =?UTF-8?q?=20=E2=80=94=20map=20CC=20credential=20to=20LoginResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/providers/anthropic-import.test.ts | 59 ++++++++++++++++++++++++++ src/providers/anthropic-import.ts | 43 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/providers/anthropic-import.test.ts create mode 100644 src/providers/anthropic-import.ts diff --git a/src/providers/anthropic-import.test.ts b/src/providers/anthropic-import.test.ts new file mode 100644 index 0000000..bb6702c --- /dev/null +++ b/src/providers/anthropic-import.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import { parseClaudeOauthBlob } from "./anthropic-import"; + +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", + ]); + }); +}); diff --git a/src/providers/anthropic-import.ts b/src/providers/anthropic-import.ts new file mode 100644 index 0000000..09489c0 --- /dev/null +++ b/src/providers/anthropic-import.ts @@ -0,0 +1,43 @@ +import type { 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; + 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(), + }, + }; +} From a26deae481fd074a1504352d10a51f6581b0d34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 19:57:22 +0200 Subject: [PATCH 13/24] =?UTF-8?q?feat(anthropic):=20claudeKeychainService?= =?UTF-8?q?=20=E2=80=94=20deterministic=20CC=20keychain=20item=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/providers/anthropic-import.test.ts | 24 +++++++++++++++++++++++- src/providers/anthropic-import.ts | 12 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/providers/anthropic-import.test.ts b/src/providers/anthropic-import.test.ts index bb6702c..45c8069 100644 --- a/src/providers/anthropic-import.test.ts +++ b/src/providers/anthropic-import.test.ts @@ -1,5 +1,6 @@ +import { createHash } from "node:crypto"; import { describe, expect, it } from "bun:test"; -import { parseClaudeOauthBlob } from "./anthropic-import"; +import { claudeKeychainService, parseClaudeOauthBlob } from "./anthropic-import"; describe("parseClaudeOauthBlob", () => { const valid = JSON.stringify({ @@ -57,3 +58,24 @@ describe("parseClaudeOauthBlob", () => { ]); }); }); + +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)); + }); +}); diff --git a/src/providers/anthropic-import.ts b/src/providers/anthropic-import.ts index 09489c0..57ed2b4 100644 --- a/src/providers/anthropic-import.ts +++ b/src/providers/anthropic-import.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { LoginResult } from "./types"; const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; @@ -41,3 +42,14 @@ export function parseClaudeOauthBlob(raw: string): LoginResult { }, }; } + +/** The macOS Keychain service name Claude Code uses for its credential item + * when `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set to `dir`. CC builds it as + * `Claude Code` + OAUTH_FILE_SUFFIX("" for a stock build) + "-credentials" + + * "-" + sha256(dir.normalize("NFC")).hex.slice(0,8). openlock sets that env to + * its own throwaway dir, so this is fully deterministic — we read exactly the + * one item CC just created, never the user's real credential. */ +export function claudeKeychainService(dir: string): string { + const suffix = createHash("sha256").update(dir.normalize("NFC")).digest("hex").slice(0, 8); + return `Claude Code-credentials-${suffix}`; +} From fbce85caf441e068544ff7f3428a550b9d4a8573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 19:58:23 +0200 Subject: [PATCH 14/24] feat(anthropic): importFromClaudeCode orchestration with injectable deps --- src/providers/anthropic-import.test.ts | 102 ++++++++++++++++++++++++- src/providers/anthropic-import.ts | 65 +++++++++++++++- 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/providers/anthropic-import.test.ts b/src/providers/anthropic-import.test.ts index 45c8069..2d921ba 100644 --- a/src/providers/anthropic-import.test.ts +++ b/src/providers/anthropic-import.test.ts @@ -1,6 +1,12 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "bun:test"; -import { claudeKeychainService, parseClaudeOauthBlob } from "./anthropic-import"; +import { + claudeKeychainService, + type ImportDeps, + importFromClaudeCode, + parseClaudeOauthBlob, +} from "./anthropic-import"; +import type { LoginIO } from "./types"; describe("parseClaudeOauthBlob", () => { const valid = JSON.stringify({ @@ -79,3 +85,97 @@ describe("claudeKeychainService", () => { 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("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 index 57ed2b4..5a0f157 100644 --- a/src/providers/anthropic-import.ts +++ b/src/providers/anthropic-import.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import type { LoginResult } from "./types"; +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"; @@ -53,3 +54,65 @@ 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}.`); + } + const raw = + deps.platform === "darwin" + ? deps.readKeychain(service as string) + : deps.readFile(join(configDir, ".credentials.json")); + 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); + } +} From 7f5bfbc4652183cacf5b4fdf22de32011b8e47cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 19:59:19 +0200 Subject: [PATCH 15/24] feat(anthropic): import token from Claude Code login; drop OAuth handshake --- src/providers/anthropic-import.ts | 46 ++++++ src/providers/anthropic-oauth.test.ts | 205 -------------------------- src/providers/anthropic-oauth.ts | 166 --------------------- src/providers/anthropic.ts | 22 +-- 4 files changed, 53 insertions(+), 386 deletions(-) delete mode 100644 src/providers/anthropic-oauth.test.ts delete mode 100644 src/providers/anthropic-oauth.ts diff --git a/src/providers/anthropic-import.ts b/src/providers/anthropic-import.ts index 5a0f157..b7717fc 100644 --- a/src/providers/anthropic-import.ts +++ b/src/providers/anthropic-import.ts @@ -1,4 +1,6 @@ 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"; @@ -116,3 +118,47 @@ export async function importFromClaudeCode(io: LoginIO, deps: ImportDeps): Promi 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-oauth.test.ts b/src/providers/anthropic-oauth.test.ts deleted file mode 100644 index 572727c..0000000 --- a/src/providers/anthropic-oauth.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - buildAuthorizeUrl, - buildPkce, - CLAUDE_OAUTH_SCOPES, - exchangeCode, - type OAuthTokens, - runLogin, -} from "./anthropic-oauth"; -import type { LoginIO } from "./types"; - -const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; -const BASE64URL = /^[A-Za-z0-9_-]+$/; - -/** A LoginIO whose readLine returns whatever `reply(printed)` computes from the - * concatenated stdout written so far. Lets a test echo back the state it sees - * in the printed authorize URL, enabling a deterministic happy path. */ -function makeIO(reply: (printed: string) => string): { - io: LoginIO; - stdout: () => string; -} { - const out: string[] = []; - const err: string[] = []; - const io: LoginIO = { - readLine: async () => reply(out.join("")), - writeStdout: (s) => out.push(s), - writeStderr: (s) => err.push(s), - isTTY: false, - }; - return { io, stdout: () => out.join("") + err.join("") }; -} - -/** Pull the `state` query param out of a printed authorize URL. */ -function extractState(printed: string): string { - const match = printed.match(/https:\/\/\S+/); - if (!match) throw new Error("no URL printed"); - const url = new URL(match[0]); - const state = url.searchParams.get("state"); - if (!state) throw new Error("no state in printed URL"); - return state; -} - -describe("buildPkce", () => { - it("produces a base64url verifier of length >= 43", () => { - const { verifier } = buildPkce(); - expect(verifier.length).toBeGreaterThanOrEqual(43); - expect(verifier).toMatch(BASE64URL); - expect(verifier).not.toContain("="); - }); - - it("produces an S256 challenge that is base64url and differs from the verifier", () => { - const { verifier, challenge } = buildPkce(); - expect(challenge).toMatch(BASE64URL); - expect(challenge).not.toContain("="); - expect(challenge).not.toBe(verifier); - }); - - it("produces unique verifiers across calls", () => { - expect(buildPkce().verifier).not.toBe(buildPkce().verifier); - }); -}); - -describe("buildAuthorizeUrl", () => { - it("uses the Claude Code subscription scope set (extracted from the CC bundle)", () => { - // CLAUDE_AI_OAUTH_SCOPES from the Claude Code bundle — the subscription set. - // No org:create_api_key (console/API mode only). - expect([...CLAUDE_OAUTH_SCOPES]).toEqual([ - "user:profile", - "user:inference", - "user:sessions:claude_code", - "user:mcp_servers", - "user:file_upload", - ]); - }); - - it("emits the expected PKCE / hosted-callback query params", () => { - const url = new URL(buildAuthorizeUrl({ challenge: "CHALLENGE", state: "STATE" })); - expect(`${url.origin}${url.pathname}`).toBe("https://claude.com/cai/oauth/authorize"); - const p = url.searchParams; - expect(p.get("response_type")).toBe("code"); - expect(p.get("client_id")).toBeTruthy(); - expect(p.get("redirect_uri")).toBe(REDIRECT_URI); - expect(p.get("scope")).toBe(CLAUDE_OAUTH_SCOPES.join(" ")); - expect(p.get("code_challenge")).toBe("CHALLENGE"); - expect(p.get("code_challenge_method")).toBe("S256"); - expect(p.get("state")).toBe("STATE"); - // hosted-callback display flow - expect(p.get("code")).toBe("true"); - }); -}); - -describe("exchangeCode", () => { - it("POSTs a JSON authorization_code body and maps the token response", async () => { - let captured: { url: string; init: RequestInit } | undefined; - const fakeFetch = (async (url: string | URL | Request, init?: RequestInit) => { - captured = { url: String(url), init: init ?? {} }; - return new Response( - JSON.stringify({ - access_token: "sk-ant-oat01-A", - refresh_token: "sk-ant-ort01-R", - expires_in: 3600, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }) as unknown as typeof fetch; - - const tokens = await exchangeCode( - { code: "THECODE", state: "THESTATE", verifier: "THEVERIFIER" }, - fakeFetch, - ); - - expect(captured).toBeDefined(); - expect(captured?.url).toBe("https://platform.claude.com/v1/oauth/token"); - const headers = new Headers(captured?.init.headers); - expect(headers.get("content-type")).toContain("application/json"); - expect(headers.get("accept")).toContain("application/json"); - - const body = JSON.parse(String(captured?.init.body)); - expect(body.grant_type).toBe("authorization_code"); - expect(body.client_id).toBeTruthy(); - expect(body.code).toBe("THECODE"); - expect(body.state).toBe("THESTATE"); - expect(body.code_verifier).toBe("THEVERIFIER"); - expect(body.redirect_uri).toBe(REDIRECT_URI); - - const t: OAuthTokens = tokens; - expect(t.access_token).toBe("sk-ant-oat01-A"); - expect(t.refresh_token).toBe("sk-ant-ort01-R"); - expect(t.client_id).toBeTruthy(); - expect(typeof t.expires_at).toBe("string"); - expect(Number.isNaN(Date.parse(t.expires_at))).toBe(false); - }); - - it("throws including status and body on non-OK", async () => { - const fakeFetch = (async () => - new Response("nope", { status: 400 })) as unknown as typeof fetch; - await expect(exchangeCode({ code: "c", state: "s", verifier: "v" }, fakeFetch)).rejects.toThrow( - /400/, - ); - }); - - it("resolves with a valid expires_at when expires_in is absent", async () => { - const fakeFetch = (async () => - new Response( - JSON.stringify({ access_token: "sk-ant-oat01-A", refresh_token: "sk-ant-ort01-R" }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as unknown as typeof fetch; - const r = await exchangeCode({ code: "c", state: "s", verifier: "v" }, fakeFetch); - expect(typeof r.expires_at).toBe("string"); - expect(Number.isNaN(Date.parse(r.expires_at))).toBe(false); - }); - - it("rejects when access_token is absent", async () => { - const fakeFetch = (async () => - new Response(JSON.stringify({ refresh_token: "sk-ant-ort01-R", expires_in: 3600 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; - await expect(exchangeCode({ code: "c", state: "s", verifier: "v" }, fakeFetch)).rejects.toThrow( - /missing access_token or refresh_token/, - ); - }); -}); - -describe("runLogin", () => { - const noopOpener = () => {}; - - it("rejects when the pasted state does not match the generated state (CSRF)", async () => { - const { io } = makeIO(() => "SOMECODE#WRONGSTATE"); - const fakeFetch = (async () => { - throw new Error("must not reach token exchange on state mismatch"); - }) as unknown as typeof fetch; - await expect(runLogin(io, { doFetch: fakeFetch, openUrl: noopOpener })).rejects.toThrow( - /state/i, - ); - }); - - it("happy path: echoes the printed state back and returns mapped tokens", async () => { - let exchanged: Record | undefined; - const fakeFetch = (async (_url: string | URL | Request, init?: RequestInit) => { - exchanged = JSON.parse(String(init?.body)); - return new Response( - JSON.stringify({ - access_token: "sk-ant-oat01-OK", - refresh_token: "sk-ant-ort01-OK", - expires_in: 3600, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }) as unknown as typeof fetch; - - // The stub reads the state out of the printed authorize URL and pastes - // back `code#state` so the CSRF check passes deterministically. - const { io } = makeIO((printed) => `THECODE#${extractState(printed)}`); - - const tokens = await runLogin(io, { doFetch: fakeFetch, openUrl: noopOpener }); - - expect(tokens.access_token).toBe("sk-ant-oat01-OK"); - expect(tokens.refresh_token).toBe("sk-ant-ort01-OK"); - expect(exchanged?.code).toBe("THECODE"); - expect(exchanged?.grant_type).toBe("authorization_code"); - // verifier sent to the token endpoint is the one minted internally - expect(exchanged?.code_verifier).toBeTruthy(); - }); -}); diff --git a/src/providers/anthropic-oauth.ts b/src/providers/anthropic-oauth.ts deleted file mode 100644 index e77a5da..0000000 --- a/src/providers/anthropic-oauth.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { spawn } from "bun"; -import type { LoginIO } from "./types"; - -// OAuth client constants. Per design decision D5 these live in source, not in -// docs/fixtures/policies. Values are extracted verbatim from the Claude Code CLI -// bundle's config object (v2.1.176) — the authoritative source. The CLAUDE_AI -// (subscription) flow authorizes at claude.com/cai; redirect + token live on -// platform.claude.com (a single shared pair for both console and subscription -// modes). Do NOT substitute claude.ai/oauth/authorize or console.anthropic.com — -// those are a different mode / stale and are rejected for this client. -const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const CLAUDE_OAUTH_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize"; // CLAUDE_AI subscription mode -export const CLAUDE_OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; -// HOSTED redirect: after consent the page displays a `code#state` string the -// user pastes back. There is no localhost server — this is a fixed constant. -const CLAUDE_OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; -// CLAUDE_AI_OAUTH_SCOPES from the bundle — the subscription scope set. Note this -// omits `org:create_api_key` (that scope belongs to the console/API mode only). -export const CLAUDE_OAUTH_SCOPES = [ - "user:profile", - "user:inference", - "user:sessions:claude_code", - "user:mcp_servers", - "user:file_upload", -] as const; - -/** Real subscription access+refresh token pair captured HOST-side. Never enters - * the sandbox. */ -export interface OAuthTokens { - access_token: string; - refresh_token: string; - /** RFC3339 timestamp at which `access_token` expires. */ - expires_at: string; - client_id: string; -} - -function base64url(buf: Buffer): string { - return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -/** PKCE pair: a high-entropy `verifier` and its S256 `challenge`. */ -export function buildPkce(): { verifier: string; challenge: string } { - const verifier = base64url(randomBytes(32)); - const challenge = base64url(createHash("sha256").update(verifier).digest()); - return { verifier, challenge }; -} - -/** Build the hosted-callback authorize URL. No `redirect_uri` parameter is - * accepted — the redirect is the fixed hosted constant. */ -export function buildAuthorizeUrl(a: { challenge: string; state: string }): string { - const url = new URL(CLAUDE_OAUTH_AUTHORIZE_URL); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", CLAUDE_OAUTH_CLIENT_ID); - url.searchParams.set("redirect_uri", CLAUDE_OAUTH_REDIRECT_URI); - url.searchParams.set("scope", CLAUDE_OAUTH_SCOPES.join(" ")); - url.searchParams.set("code_challenge", a.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", a.state); - // Required for the hosted-callback display flow (renders code#state for paste-back). - url.searchParams.set("code", "true"); - return url.toString(); -} - -/** Exchange an authorization code for tokens against the Claude token endpoint. */ -export async function exchangeCode( - a: { code: string; state: string; verifier: string }, - doFetch: typeof fetch = fetch, -): Promise { - const res = await doFetch(CLAUDE_OAUTH_TOKEN_URL, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "authorization_code", - client_id: CLAUDE_OAUTH_CLIENT_ID, - code: a.code, - state: a.state, - redirect_uri: CLAUDE_OAUTH_REDIRECT_URI, - code_verifier: a.verifier, - }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`OAuth token exchange failed: ${res.status} ${res.statusText} ${text}`.trim()); - } - const json = (await res.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number | string; - }; - if (!json.access_token || !json.refresh_token) { - throw new Error( - "OAuth token exchange returned an incomplete response (missing access_token or refresh_token)", - ); - } - // Fall back to 3600 s when expires_in is absent or non-numeric. A short - // fallback causes an early gateway refresh (harmless — refresh_token is - // present). An over-long expiry would silently break inference once the - // access_token actually expires, so erring short is always the safer choice. - const ttl = - Number.isFinite(Number(json.expires_in)) && Number(json.expires_in) > 0 - ? Number(json.expires_in) - : 3600; - return { - access_token: json.access_token, - refresh_token: json.refresh_token, - expires_at: new Date(Date.now() + ttl * 1000).toISOString(), - client_id: CLAUDE_OAUTH_CLIENT_ID, - }; -} - -/** Best-effort browser open on macOS. Never throws. */ -function defaultOpenUrl(url: string): void { - try { - const proc = spawn({ cmd: ["open", url], stdout: "ignore", stderr: "ignore" }); - proc.unref(); - } catch { - // `open` unavailable (non-macOS, sandbox, etc.) — printing the URL is enough. - } -} - -export interface RunLoginOptions { - doFetch?: typeof fetch; - openUrl?: (url: string) => void; -} - -/** Interactive paste-back driver. Public signature the provider calls is - * `runLogin(io)`; `opts` exists only so tests can inject fetch/opener. */ -export async function runLogin(io: LoginIO, opts: RunLoginOptions = {}): Promise { - const doFetch = opts.doFetch ?? fetch; - const openUrl = opts.openUrl ?? defaultOpenUrl; - - const { verifier, challenge } = buildPkce(); - const state = base64url(randomBytes(16)); - const authorizeUrl = buildAuthorizeUrl({ challenge, state }); - - io.writeStdout( - "To authorize openlock with your Claude subscription, open this URL in your browser:\n\n", - ); - io.writeStdout(`${authorizeUrl}\n\n`); - io.writeStdout( - "After approving, the page shows a code (format: code#state). Copy it and paste it below.\n", - ); - openUrl(authorizeUrl); - - const pasted = (await io.readLine("Paste the code shown after authorizing:\n> ")).trim(); - if (!pasted) throw new Error("No authorization code entered."); - - const hashIdx = pasted.indexOf("#"); - if (hashIdx === -1) { - throw new Error( - "Pasted value is not in the expected `code#state` form — copy the full string the callback page displays.", - ); - } - const code = pasted.slice(0, hashIdx).trim(); - const returnedState = pasted.slice(hashIdx + 1).trim(); - if (returnedState !== state) { - throw new Error("OAuth state mismatch (possible CSRF) — aborting login."); - } - if (!code) throw new Error("No authorization code found in pasted value."); - - return exchangeCode({ code, state, verifier }, doFetch); -} diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 9a72ada..ddbb39f 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,5 +1,5 @@ import type { Harness } from "../sandbox/harness"; -import { CLAUDE_OAUTH_SCOPES, CLAUDE_OAUTH_TOKEN_URL, runLogin } from "./anthropic-oauth"; +import { importFromClaudeCode, realImportDeps } from "./anthropic-import"; import type { LoginIO, LoginResult, @@ -35,20 +35,12 @@ export const ANTHROPIC: ProviderPlugin = { compatibleHarnesses: new Set(["claude_code"]), async loginInteractive(io: LoginIO): Promise { - const t = await runLogin(io); - return { - // RAW access token — NO "Bearer " prefix. The gateway prepends "Bearer " - // via the policy cred_inject value_prefix at egress. - credentials: { ANTHROPIC_BEARER_TOKEN: t.access_token }, - refresh: { - strategy: "oauth2_refresh_token", - token_url: CLAUDE_OAUTH_TOKEN_URL, - scopes: [...CLAUDE_OAUTH_SCOPES], - client_id: t.client_id, - refresh_token: t.refresh_token, - access_expires_at: t.expires_at, - }, - }; + // 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[] { From e2381466ca4d26644190a25d426daa0b9d79a83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 20:00:11 +0200 Subject: [PATCH 16/24] feat(sandbox): --no-attach flag plumbing --- src/cli/sandbox.test.ts | 9 +++++++++ src/cli/sandbox.ts | 2 ++ src/sandbox/session.ts | 3 +++ 3 files changed, 14 insertions(+) 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/sandbox/session.ts b/src/sandbox/session.ts index c69ddbf..5f9e339 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -70,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 { From 1908d0dbe8ca59fbc03b6c823f20f9779530a7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 20:00:49 +0200 Subject: [PATCH 17/24] feat(sandbox): --no-attach detached create (skip harness attach), close openlock-dr9 --- src/sandbox/session.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index 5f9e339..8bb0991 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -697,6 +697,21 @@ 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 -- `. The session is never-attached + // (lastAttachedAt: null), so classifySession returns idle-recent and it is + // NOT auto-reaped. Keep the gateway alive (>=1 session) and exit cleanly: + // the tether + gateway client otherwise keep the compiled-bun event loop + // from draining and the CLI would hang here (see openlock-to9). + 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 }), From efb071bf483d4184b09de79f3630adf42f43079a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 20:01:57 +0200 Subject: [PATCH 18/24] style(anthropic): biome format import module + test --- src/providers/anthropic-import.test.ts | 8 ++++---- src/providers/anthropic-import.ts | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/providers/anthropic-import.test.ts b/src/providers/anthropic-import.test.ts index 2d921ba..65b01e0 100644 --- a/src/providers/anthropic-import.test.ts +++ b/src/providers/anthropic-import.test.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; import { claudeKeychainService, type ImportDeps, @@ -150,9 +150,9 @@ describe("importFromClaudeCode", () => { }); 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/, - ); + await expect( + importFromClaudeCode(silentIO(), baseDeps({ hasClaude: () => false })), + ).rejects.toThrow(/not found on PATH/); }); it("throws when the login subprocess exits non-zero", async () => { diff --git a/src/providers/anthropic-import.ts b/src/providers/anthropic-import.ts index b7717fc..c1b2edf 100644 --- a/src/providers/anthropic-import.ts +++ b/src/providers/anthropic-import.ts @@ -19,7 +19,10 @@ export function parseClaudeOauthBlob(raw: string): LoginResult { } catch { throw new Error("Claude Code credential is not valid JSON."); } - const o = ((parsed.claudeAiOauth as Record) ?? parsed) as Record; + 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) { From c91475503856731e7428552eaaf1d42f86f0f28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 20:11:34 +0200 Subject: [PATCH 19/24] =?UTF-8?q?fix(anthropic,sandbox):=20macOS=20keychai?= =?UTF-8?q?n=E2=86=92file=20harvest=20fallback;=20reset=20detached=20sessi?= =?UTF-8?q?on=20meta=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/providers/anthropic-import.test.ts | 18 ++++++++++++++++++ src/providers/anthropic-import.ts | 24 +++++++++++++++++------- src/sandbox/session.ts | 16 +++++++++++----- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/providers/anthropic-import.test.ts b/src/providers/anthropic-import.test.ts index 65b01e0..9f0e550 100644 --- a/src/providers/anthropic-import.test.ts +++ b/src/providers/anthropic-import.test.ts @@ -149,6 +149,24 @@ describe("importFromClaudeCode", () => { 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 })), diff --git a/src/providers/anthropic-import.ts b/src/providers/anthropic-import.ts index c1b2edf..61f5932 100644 --- a/src/providers/anthropic-import.ts +++ b/src/providers/anthropic-import.ts @@ -49,12 +49,15 @@ export function parseClaudeOauthBlob(raw: string): LoginResult { }; } -/** The macOS Keychain service name Claude Code uses for its credential item - * when `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set to `dir`. CC builds it as +/** 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.normalize("NFC")).hex.slice(0,8). openlock sets that env to - * its own throwaway dir, so this is fully deterministic — we read exactly the - * one item CC just created, never the user's real credential. */ + * "-" + 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}`; @@ -100,10 +103,17 @@ export async function importFromClaudeCode(io: LoginIO, deps: ImportDeps): Promi 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(join(configDir, ".credentials.json")); + ? (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?", diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index 8bb0991..384aedd 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -701,11 +701,17 @@ export async function runSandbox(opts: SandboxOpts): Promise { 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 -- `. The session is never-attached - // (lastAttachedAt: null), so classifySession returns idle-recent and it is - // NOT auto-reaped. Keep the gateway alive (>=1 session) and exit cleanly: - // the tether + gateway client otherwise keep the compiled-bun event loop - // from draining and the CLI would hang here (see openlock-to9). + // 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); From 8c7522ea3bddbae5a8b488e14cd7f8c32b1777db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 20:12:18 +0200 Subject: [PATCH 20/24] =?UTF-8?q?docs:=20README=20=E2=80=94=20first=20sand?= =?UTF-8?q?box=20run=20runs=20'openlock=20login'=20(subscription=20import)?= =?UTF-8?q?,=20not=20setup-token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From 9e825b0297337f3670cec7027c33267cbc015661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 20:54:21 +0200 Subject: [PATCH 21/24] =?UTF-8?q?fix(ensure-provider):=20profile=20import?= =?UTF-8?q?=20is=20not=20idempotent=20=E2=80=94=20probe=20via=20'profile?= =?UTF-8?q?=20export',=20import=20only=20when=20absent=20(fixes=20reattach?= =?UTF-8?q?/re-seed=20crash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sandbox/claude-oauth-profile.ts | 9 ++++++-- src/sandbox/ensure-provider.test.ts | 33 +++++++++++++++++++++++++---- src/sandbox/ensure-provider.ts | 30 ++++++++++++++++---------- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/sandbox/claude-oauth-profile.ts b/src/sandbox/claude-oauth-profile.ts index 21a8746..2782744 100644 --- a/src/sandbox/claude-oauth-profile.ts +++ b/src/sandbox/claude-oauth-profile.ts @@ -1,9 +1,14 @@ 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 (idempotently) gives the + * 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. @@ -14,7 +19,7 @@ import type { ProviderRefreshMaterial } from "../tokens"; */ export function buildClaudeOAuthProfileYaml(m: ProviderRefreshMaterial): string { return yaml.dump({ - id: "claude-oauth", + id: CLAUDE_OAUTH_PROFILE_ID, display_name: "Claude (OAuth subscription)", category: "agent", inference_capable: true, diff --git a/src/sandbox/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index b9d7378..d05d04b 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -45,7 +45,7 @@ describe("providerExistsInGateway", () => { }); describe("_ensureProviderForTests", () => { - function makeShell(state: { existing: string[] }) { + function makeShell(state: { existing: string[]; profilePresent?: boolean }) { const calls: string[][] = []; return { calls, @@ -58,6 +58,13 @@ describe("_ensureProviderForTests", () => { stderr: "", }; } + // profile export: existence probe (exit 0 = present, nonzero = absent). + if (args[1] === "profile" && args[2] === "export") { + return { exitCode: state.profilePresent ? 0 : 1, stdout: "", stderr: "" }; + } + // profile import registers the profile (mirrors the gateway's behavior: + // a SECOND import of the same id would error, so we only get here when absent). + if (args[1] === "profile" && args[2] === "import") state.profilePresent = true; // 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: "" }; @@ -166,14 +173,32 @@ describe("_ensureProviderForTests", () => { it("never clobbers when present: no create/update/refresh-configure", async () => { writeAnthropic(); - const m = makeShell({ existing: ["anthropic"] }); + 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(); - // idempotent profile import still runs. - expect(verb(m.calls, "profile", "import")).toBeDefined(); + // 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 () => { diff --git a/src/sandbox/ensure-provider.ts b/src/sandbox/ensure-provider.ts index 15658b2..4965efd 100644 --- a/src/sandbox/ensure-provider.ts +++ b/src/sandbox/ensure-provider.ts @@ -5,7 +5,7 @@ import { PROVIDERS } from "../providers/registry"; import type { ProviderId } from "../providers/types"; import type { ProviderRecord } from "../tokens"; import { readProvider } from "../tokens"; -import { buildClaudeOAuthProfileYaml } from "./claude-oauth-profile"; +import { buildClaudeOAuthProfileYaml, CLAUDE_OAUTH_PROFILE_ID } from "./claude-oauth-profile"; import { getCliInvocation } from "./fork-binaries"; interface ShellResult { @@ -96,16 +96,24 @@ async function seedRefreshProvider( throw new Error(`seedRefreshProvider called for '${providerId}' without refresh material`); } - const dir = mkdtempSync(join(tmpdir(), "olk-prof-")); - try { - const profPath = join(dir, "claude-oauth.yaml"); - writeFileSync(profPath, buildClaudeOAuthProfileYaml(refresh)); - // `provider profile import` is idempotent, so we run it on every ensure - // regardless of whether the provider already exists. It awaits via mustOk, - // so it has fully completed before the finally removes the temp dir. - await mustOk(shell, ["provider", "profile", "import", "--file", profPath]); - } finally { - rmSync(dir, { recursive: true, force: true }); + // `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) { From ee668a208f083acc1c4401f3a5d3c478d4170f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 21:10:45 +0200 Subject: [PATCH 22/24] =?UTF-8?q?fix(sandbox):=20tether=20must=20not=20inh?= =?UTF-8?q?erit=20stdout=20=E2=80=94=20detached=20create=20(--no-attach)?= =?UTF-8?q?=20hung=20any=20piped/CI=20stdout=20capture=20(sleep-infinity?= =?UTF-8?q?=20child=20held=20the=20pipe=20open)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sandbox/container.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index ee94597..08873cd 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -166,7 +166,16 @@ export function openshellSandboxCreateAsync(args: OpenshellCreateArgs): Promise< const proc = Bun.spawn(argv, { cwd: cli.cwd, stdin: "ignore", - stdout: "inherit", + // This child is the persistent container tether (`… exec sleep infinity`) + // and lives for the whole session. It must NOT inherit openlock's stdout: + // a detached create (`openlock sandbox --no-attach`) exits via process.exit + // while the tether keeps running, and an inherited stdout fd would keep the + // caller's pipe open forever — hanging any scripted/CI capture + // (`SESSION=$(openlock sandbox --no-attach …)`), which is exactly the + // detached-create use case. Diagnostics still surface via the filtered + // stderr below and the process exit code. (stderr is "pipe"+drained, so it + // likewise doesn't hold the parent's fd.) + stdout: "ignore", stderr: "pipe", }); void pipeFilteredStderr(proc.stderr); From 69600a1295c0df2f86d028078464d1a81c0d6264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 21:30:53 +0200 Subject: [PATCH 23/24] test(ensure-provider): extract fake-gateway responder to bound cognitive complexity --- src/sandbox/ensure-provider.test.ts | 43 +++++++++++++++++------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/sandbox/ensure-provider.test.ts b/src/sandbox/ensure-provider.test.ts index d05d04b..73305b6 100644 --- a/src/sandbox/ensure-provider.test.ts +++ b/src/sandbox/ensure-provider.test.ts @@ -44,30 +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[]; profilePresent?: boolean }) { + 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: "", - }; - } - // profile export: existence probe (exit 0 = present, nonzero = absent). - if (args[1] === "profile" && args[2] === "export") { - return { exitCode: state.profilePresent ? 0 : 1, stdout: "", stderr: "" }; - } - // profile import registers the profile (mirrors the gateway's behavior: - // a SECOND import of the same id would error, so we only get here when absent). - if (args[1] === "profile" && args[2] === "import") state.profilePresent = true; - // 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); }, }; } From 4f345f18d5e2eb1be9f88306e42f676d8a99f63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 13 Jun 2026 21:59:23 +0200 Subject: [PATCH 24/24] =?UTF-8?q?refactor(sandbox):=20TETHER=5FSTDIO=20con?= =?UTF-8?q?st=20+=20tripwire=20test=20=E2=80=94=20standardise=20guard=20ag?= =?UTF-8?q?ainst=20outlives-CLI=20stdout-inherit=20hangs=20(openlock-sqw)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sandbox/container.test.ts | 15 +++++++++++++++ src/sandbox/container.ts | 36 ++++++++++++++++++++--------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index 5cbc74c..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"]); diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index 08873cd..cdb00e9 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -160,24 +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", - // This child is the persistent container tether (`… exec sleep infinity`) - // and lives for the whole session. It must NOT inherit openlock's stdout: - // a detached create (`openlock sandbox --no-attach`) exits via process.exit - // while the tether keeps running, and an inherited stdout fd would keep the - // caller's pipe open forever — hanging any scripted/CI capture - // (`SESSION=$(openlock sandbox --no-attach …)`), which is exactly the - // detached-create use case. Diagnostics still surface via the filtered - // stderr below and the process exit code. (stderr is "pipe"+drained, so it - // likewise doesn't hold the parent's fd.) - stdout: "ignore", - stderr: "pipe", - }); + const proc = Bun.spawn(argv, { cwd: cli.cwd, ...TETHER_STDIO }); void pipeFilteredStderr(proc.stderr); return { pid: proc.pid,