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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,38 @@ 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
with:
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
Expand All @@ -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 }}
Expand Down
115 changes: 115 additions & 0 deletions packages/browser-loop/test/anthropic-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }[] = [];
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");
});
});
Original file line number Diff line number Diff line change
@@ -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 = [
{
Expand All @@ -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,
{
Expand All @@ -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),
},
Expand Down
44 changes: 44 additions & 0 deletions packages/browser-loop/test/support/anthropic-auth.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
headers?: Record<string, string>;
}

/**
* 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<AnthropicRequestAuth>) | 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() },

Check failure on line 41 in packages/browser-loop/test/support/anthropic-auth.ts

View workflow job for this annotation

GitHub Actions / integration

test/anthropic-native.integration.test.ts > Anthropic client toolsets > browser survives catalog and pi-ai serialization

Error: Token exchange failed with status 401 (request-id req_011Cgt7c8E38zCxwUbk5ZDLt): {"error":{"type":"authentication_error","message":"Authentication failed"}} Ensure your federation rule matches your identity token. If your federation rule is scoped to multiple workspaces, set the ANTHROPIC_WORKSPACE_ID environment variable, the 'workspace_id' config key, or the `workspaceId` option. View your authentication events in the Workload identity page of Claude Console for more details. ❯ TokenCache.provider node_modules/@anthropic-ai/sdk/src/lib/credentials/oidc-federation.ts:114:13 ❯ TokenCache.getToken node_modules/@anthropic-ai/sdk/src/lib/credentials/token-cache.ts:44:21 ❯ test/support/anthropic-auth.ts:41:32 ❯ test/anthropic-native.integration.test.ts:31:37 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 401, body: '{"error":{"type":"authentication_error","message":"Authentication failed"}}', requestId: 'req_011Cgt7c8E38zCxwUbk5ZDLt' }

Check failure on line 41 in packages/browser-loop/test/support/anthropic-auth.ts

View workflow job for this annotation

GitHub Actions / integration

test/anthropic-native.integration.test.ts > Anthropic client toolsets > computer survives catalog and pi-ai serialization

Error: Token exchange failed with status 401 (request-id req_011Cgt7c7mkoT3c8WLRersqc): {"error":{"type":"authentication_error","message":"Authentication failed"}} Ensure your federation rule matches your identity token. If your federation rule is scoped to multiple workspaces, set the ANTHROPIC_WORKSPACE_ID environment variable, the 'workspace_id' config key, or the `workspaceId` option. View your authentication events in the Workload identity page of Claude Console for more details. ❯ TokenCache.provider node_modules/@anthropic-ai/sdk/src/lib/credentials/oidc-federation.ts:114:13 ❯ TokenCache.getToken node_modules/@anthropic-ai/sdk/src/lib/credentials/token-cache.ts:44:21 ❯ test/support/anthropic-auth.ts:41:32 ❯ test/anthropic-native.integration.test.ts:31:37 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 401, body: '{"error":{"type":"authentication_error","message":"Authentication failed"}}', requestId: 'req_011Cgt7c7mkoT3c8WLRersqc' }
headers: { "anthropic-beta": OAUTH_API_BETA_HEADER },
});
}
Loading