diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 594d8b2..2a1aa82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,11 @@ jobs: # Only run on the main repo (not forks) so secrets are available. # Skip draft PRs; still run on every commit to non-draft PRs and on main pushes. if: github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft) + # id-token: write lets the job exchange its GitHub OIDC token for + # short-lived Anthropic credentials instead of a stored API key. + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -100,14 +105,26 @@ jobs: node-version: 22 cache: npm - run: npm ci + # Keeps a fresh GitHub OIDC token in ANTHROPIC_IDENTITY_TOKEN_FILE and + # exports the federation settings the Anthropic test auth reads. + - name: Anthropic credentials + uses: kernel/security-workflows/.github/actions/llm-credentials@4144994f1d6977e07ef5a5832599ee4faaae9550 + with: + model: anthropic/claude-opus-5 + anthropic-federation-rule-id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic-organization-id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic-service-account-id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} - name: Integration tests env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} run: npm run test:integration --workspace @onkernel/browser-loop + - name: Stop Anthropic credentials + if: always() + uses: kernel/security-workflows/.github/actions/llm-credentials@4144994f1d6977e07ef5a5832599ee4faaae9550 + with: + stop: true live-e2e: runs-on: ubuntu-latest @@ -126,8 +143,6 @@ jobs: env: LOOP_E2E_LIVE: "1" KERNEL_API_KEY: ${{ secrets.KERNEL_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} diff --git a/packages/browser-loop/test/anthropic-auth.test.ts b/packages/browser-loop/test/anthropic-auth.test.ts new file mode 100644 index 0000000..dcefadb --- /dev/null +++ b/packages/browser-loop/test/anthropic-auth.test.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createLoopModels } from "../src/pi/index"; +import { compileLoopToolCatalog, loop } from "../src/index"; +import { anthropicRequestAuth } from "./support/anthropic-auth"; + +let dir: string; +let tokenFile: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "anthropic-auth-")); + tokenFile = join(dir, "identity-token"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function federationEnv(): NodeJS.ProcessEnv { + return { + ANTHROPIC_FEDERATION_RULE_ID: "fdrl_test", + ANTHROPIC_ORGANIZATION_ID: "org_test", + ANTHROPIC_SERVICE_ACCOUNT_ID: "svac_test", + ANTHROPIC_IDENTITY_TOKEN_FILE: tokenFile, + }; +} + +function tokenEndpoint(responses: { access_token: string; expires_in: number }[]) { + const requests: { url: string; body: Record }[] = []; + const fetch = (async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + const next = responses.shift(); + if (!next) throw new Error("unexpected token exchange"); + return new Response(JSON.stringify({ ...next, token_type: "Bearer" }), { status: 200 }); + }) as typeof globalThis.fetch; + return { fetch, requests }; +} + +describe("anthropicRequestAuth", () => { + it("uses ANTHROPIC_API_KEY when set, even with federation configured", async () => { + writeFileSync(tokenFile, "jwt-1"); + const auth = anthropicRequestAuth({ ...federationEnv(), ANTHROPIC_API_KEY: "sk-ant-test" }); + await expect(auth!()).resolves.toEqual({ apiKey: "sk-ant-test" }); + }); + + it("returns undefined without a key or complete federation settings", () => { + expect(anthropicRequestAuth({})).toBeUndefined(); + expect(anthropicRequestAuth({ ...federationEnv(), ANTHROPIC_IDENTITY_TOKEN_FILE: undefined })).toBeUndefined(); + }); + + it("exchanges the identity token once and reuses the access token while fresh", async () => { + writeFileSync(tokenFile, "jwt-1\n"); + const endpoint = tokenEndpoint([{ access_token: "access-1", expires_in: 600 }]); + const auth = anthropicRequestAuth(federationEnv(), endpoint.fetch)!; + + const expected = { env: { ANTHROPIC_AUTH_TOKEN: "access-1" }, headers: { "anthropic-beta": "oauth-2025-04-20" } }; + await expect(auth()).resolves.toEqual(expected); + await expect(auth()).resolves.toEqual(expected); + expect(endpoint.requests).toEqual([{ + url: "https://api.anthropic.com/v1/oauth/token", + body: { + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: "jwt-1", + federation_rule_id: "fdrl_test", + organization_id: "org_test", + service_account_id: "svac_test", + }, + }]); + }); + + it("re-reads the identity token file and re-exchanges near expiry", async () => { + writeFileSync(tokenFile, "jwt-1"); + const endpoint = tokenEndpoint([ + { access_token: "access-1", expires_in: 10 }, + { access_token: "access-2", expires_in: 600 }, + ]); + const auth = anthropicRequestAuth(federationEnv(), endpoint.fetch)!; + + expect((await auth()).env?.ANTHROPIC_AUTH_TOKEN).toBe("access-1"); + writeFileSync(tokenFile, "jwt-2"); + expect((await auth()).env?.ANTHROPIC_AUTH_TOKEN).toBe("access-2"); + expect(endpoint.requests.map((request) => request.body.assertion)).toEqual(["jwt-1", "jwt-2"]); + }); + + it("sends the federated token through pi's Anthropic transport without an API key", async () => { + writeFileSync(tokenFile, "jwt-1"); + const auth = anthropicRequestAuth(federationEnv(), tokenEndpoint([{ access_token: "access-1", expires_in: 600 }]).fetch)!; + const { apiKey, env, headers } = await auth(); + const catalog = compileLoopToolCatalog({ + model: "anthropic:claude-opus-5", + requestedTools: [loop.providers.anthropic.tools.browser({ version: "20260801", javascript: true })], + }); + let sent: Headers | undefined; + await createLoopModels().complete( + catalog.model, + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }], tools: [...catalog.toolDeclarations] }, + { + apiKey, + env, + headers: catalog.headers.merge(headers), + loopIncomingToolPlan: catalog.incoming, + maxRetries: 0, + fetch: async (_url, init) => { + sent = new Headers(init?.headers); + return new Response("{}", { status: 400 }); + }, + }, + ); + expect(sent?.get("authorization")).toBe("Bearer access-1"); + expect(sent?.get("x-api-key")).toBeNull(); + expect(sent?.get("anthropic-beta")?.split(",")).toContain("oauth-2025-04-20"); + }); +}); diff --git a/packages/browser-loop/test/anthropic-native.integration.test.ts b/packages/browser-loop/test/anthropic-native.integration.test.ts index 3b64898..b79a659 100644 --- a/packages/browser-loop/test/anthropic-native.integration.test.ts +++ b/packages/browser-loop/test/anthropic-native.integration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import { createLoopModels } from "../src/pi/index"; import { compileLoopToolCatalog, loop } from "../src/index"; +import { anthropicRequestAuth } from "./support/anthropic-auth"; -const apiKey = process.env.ANTHROPIC_API_KEY; -const liveIt = apiKey ? it : it.skip; +const auth = anthropicRequestAuth(); +const liveIt = auth ? it : it.skip; const cases = [ { @@ -27,6 +28,7 @@ describe("Anthropic client toolsets", () => { model: "anthropic:claude-opus-5", requestedTools: [current.tool], }); + const { apiKey, env, headers } = await auth!(); const response = await createLoopModels().complete( catalog.model, { @@ -36,8 +38,9 @@ describe("Anthropic client toolsets", () => { }, { apiKey, + env, maxTokens: 1_024, - headers: catalog.headers.merge(), + headers: catalog.headers.merge(headers), loopIncomingToolPlan: catalog.incoming, onPayload: (payload, model) => catalog.payload.apply(payload, model), }, diff --git a/packages/browser-loop/test/support/anthropic-auth.ts b/packages/browser-loop/test/support/anthropic-auth.ts new file mode 100644 index 0000000..c44d9df --- /dev/null +++ b/packages/browser-loop/test/support/anthropic-auth.ts @@ -0,0 +1,44 @@ +import { identityTokenFromFile } from "@anthropic-ai/sdk/lib/credentials/identity-token"; +import { oidcFederationProvider } from "@anthropic-ai/sdk/lib/credentials/oidc-federation"; +import { TokenCache } from "@anthropic-ai/sdk/lib/credentials/token-cache"; +import { OAUTH_API_BETA_HEADER } from "@anthropic-ai/sdk/lib/credentials/types"; + +export interface AnthropicRequestAuth { + apiKey?: string; + env?: Record; + headers?: Record; +} + +/** + * Per-request Anthropic auth for live tests. `ANTHROPIC_API_KEY` wins when set; + * otherwise the SDK's workload identity federation exchanges the identity token + * in `ANTHROPIC_IDENTITY_TOKEN_FILE` (re-read on every exchange) and caches the + * access token until shortly before it expires. pi's Anthropic transport takes + * neither an SDK credential provider nor, with client toolsets, a custom client, + * so the token goes through pi's per-request `ANTHROPIC_AUTH_TOKEN` (sent as a + * bearer token) with the OAuth beta header the API requires for it. Returns + * undefined when neither is configured. + */ +export function anthropicRequestAuth( + env: NodeJS.ProcessEnv = process.env, + fetch: typeof globalThis.fetch = globalThis.fetch, +): (() => Promise) | undefined { + const apiKey = env.ANTHROPIC_API_KEY; + if (apiKey) return async () => ({ apiKey }); + const federationRuleId = env.ANTHROPIC_FEDERATION_RULE_ID; + const organizationId = env.ANTHROPIC_ORGANIZATION_ID; + const identityTokenFile = env.ANTHROPIC_IDENTITY_TOKEN_FILE; + if (!federationRuleId || !organizationId || !identityTokenFile) return undefined; + const tokens = new TokenCache(oidcFederationProvider({ + identityTokenProvider: identityTokenFromFile(identityTokenFile), + federationRuleId, + organizationId, + serviceAccountId: env.ANTHROPIC_SERVICE_ACCOUNT_ID, + baseURL: "https://api.anthropic.com", + fetch, + })); + return async () => ({ + env: { ANTHROPIC_AUTH_TOKEN: await tokens.getToken() }, + headers: { "anthropic-beta": OAUTH_API_BETA_HEADER }, + }); +}