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
30 changes: 30 additions & 0 deletions apps/agent-orchestrator/src/agent/authorization-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,36 @@ describe("AuthorizationService.authorize -- claude-remote write-back grant", ()
expect(verdict.secretEnv).toContainEqual({ name: "CLAUDE_CREDENTIALS_WRITEBACK_TOKEN", value: "wb-token" });
});

// The agent's pod and its logs are long gone before anyone asks why a run
// reported an expired credential, so this line is the only durable record of
// whether the credential was already dead at launch (a rotation that went
// unpersisted) or died during the turn.
it("logs the injected claude-remote credential's expiry -- and no token material", async () => {
const expiresAt = Date.UTC(2026, 6, 28, 12, 0, 0);
const blob = JSON.stringify({ claudeAiOauth: { accessToken: "sk-ant-SECRET", refreshToken: "rt-SECRET", expiresAt } });
const logged: string[] = [];
const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logged.push(args.map(String).join(" "));
});
try {
const svc = new AuthorizationService({
claudeRemoteGateway: gateway({ "claude-remote": { token: { token: blob } } }),
agentRunTimeoutSeconds: 600,
});
await svc.authorize({
agent: { id: "swe", identityProviders: ["claude-remote"] },
identity: identity({ subject: "openwebui:alice", principal: "github:alice" }),
request: "r",
});
} finally {
spy.mockRestore();
}

const authorized = logged.find((l) => l.includes('"verdict":"authorized"')) ?? "";
expect(authorized).toContain('"claudeLoginExpiresAt":"2026-07-28T12:00:00.000Z"');
expect(authorized).not.toContain("SECRET");
});

it("launches without write-back rather than failing when no grant is available", async () => {
const svc = new AuthorizationService({
claudeRemoteGateway: gateway({ "claude-remote": { token: { token: "{}" } } }),
Expand Down
30 changes: 30 additions & 0 deletions apps/agent-orchestrator/src/agent/authorization-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ export const PROVIDER_ENV_VAR: Record<string, string> = {
"claude-remote": "CLAUDE_LOGIN_CREDENTIALS_JSON",
};

/**
* The expiry of the `claude-remote` credentials blob about to be injected, for
* the `authorized` log line -- or `null` when this run carries none, or the
* blob has no readable `expiresAt`.
*
* Expiry ONLY. This function is handed the one structure that holds every
* resolved credential for a run, so it must never return anything derived from
* a token's VALUE (see `logVerdict`'s own warning).
*/
function claudeLoginExpiry(secretEnv: Array<{ name: string; value: string }> | undefined): string | null {
const blob = secretEnv?.find((e) => e.name === PROVIDER_ENV_VAR["claude-remote"])?.value;
if (!blob) return null;
try {
const parsed = JSON.parse(blob) as Record<string, unknown>;
const inner = (parsed.claudeAiOauth ?? parsed) as Record<string, unknown>;
const raw = inner.expiresAt;
const ms = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
} catch {
return null;
}
}

/**
* Env vars a `claude-remote` launch carries so the run can persist the
* credentials its Claude Code CLI refreshes in-pod (the gateway's
Expand Down Expand Up @@ -649,6 +672,13 @@ export class AuthorizationService {
injecting: (secretEnv ?? []).map((e) => e.name),
actorLogin: actorLogin ?? null,
principal,
// Expiry ONLY, of the Remote Control credential this run is being handed.
// The agent's own pod (and its logs) are gone long before anyone asks why
// a run reported an expired credential, so this is the one durable record
// of whether it was already dead at launch or died during the turn --
// which is the difference between a rotation that went unpersisted and a
// credential resolved from the wrong record.
claudeLoginExpiresAt: claudeLoginExpiry(secretEnv),
});
return {
kind: "authorized",
Expand Down
165 changes: 164 additions & 1 deletion apps/claude-code-swe-agent/src/credentialsWriteback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { persistRefreshedCredentials } from "./credentialsWriteback.js";
import { createCredentialsWritebackWatcher, credentialExpiry, persistRefreshedCredentials } from "./credentialsWriteback.js";

function homeWithCredentials(contents: string | null): string {
const home = mkdtempSync(join(tmpdir(), "swe-writeback-"));
Expand Down Expand Up @@ -104,3 +104,166 @@ describe("persistRefreshedCredentials", () => {
expect(fetchImpl).not.toHaveBeenCalled();
});
});

describe("credentialExpiry", () => {
it("reads expiresAt from a real credentials shape, and never returns token material", () => {
const at = Date.UTC(2026, 6, 28, 12, 0, 0);
const blob = JSON.stringify({ claudeAiOauth: { accessToken: "secret-token", refreshToken: "r", expiresAt: at } });
const expiry = credentialExpiry(blob);
expect(expiry).toBe("2026-07-28T12:00:00.000Z");
expect(expiry).not.toContain("secret-token");
});

it("returns null for junk rather than throwing (it only ever feeds a log line)", () => {
expect(credentialExpiry("not json")).toBeNull();
expect(credentialExpiry("{}")).toBeNull();
expect(credentialExpiry(JSON.stringify({ claudeAiOauth: { expiresAt: "nope" } }))).toBeNull();
});
});

/**
* The watcher exists because a refresh ROTATES the stored refresh token, so a
* refresh this pod fails to report does not degrade the link -- it kills it,
* and only a human re-link recovers. Every test here is a way that used to
* happen.
*/
describe("createCredentialsWritebackWatcher", () => {
const settle = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));

it("persists a refresh that happens DURING the turn, without waiting for the turn to end", async () => {
const home = homeWithCredentials(SEEDED);
const fetchImpl = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
const watcher = createCredentialsWritebackWatcher({
homeDir: home,
url: "https://gateway.example/refresh",
token: "grant-abc",
seeded: SEEDED,
intervalMs: 10,
fetchImpl: fetchImpl as unknown as typeof fetch,
log: () => {},
});

watcher.start();
// The CLI refreshes mid-turn. A pod killed right here used to lose this.
writeFileSync(join(home, ".claude", ".credentials.json"), REFRESHED);
await settle(60);

expect(fetchImpl).toHaveBeenCalled();
expect(JSON.parse(String((fetchImpl.mock.calls[0] as [string, RequestInit])[1].body))).toEqual({
credentialsJson: REFRESHED,
});
await watcher.stop();
});

it("stores each refresh once, not on every tick", async () => {
const home = homeWithCredentials(REFRESHED);
const fetchImpl = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
const watcher = createCredentialsWritebackWatcher({
homeDir: home,
url: "https://gateway.example/refresh",
token: "grant-abc",
seeded: SEEDED,
intervalMs: 10,
fetchImpl: fetchImpl as unknown as typeof fetch,
log: () => {},
});

watcher.start();
await settle(80); // many ticks
await watcher.stop();

// One store for the one refresh: `lastPersisted` advanced, so later ticks
// (and the final flush) see an unchanged file.
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

// The gateway restarts on every push to main (release.yml), and a POST that
// landed in that gap used to lose the refresh permanently.
it("keeps retrying a failing gateway on later ticks, and stores once it recovers", async () => {
const home = homeWithCredentials(REFRESHED);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 503 } as Response)
.mockResolvedValueOnce({ ok: false, status: 503 } as Response)
.mockResolvedValue({ ok: true, status: 200 } as Response);
const watcher = createCredentialsWritebackWatcher({
homeDir: home,
url: "https://gateway.example/refresh",
token: "grant-abc",
seeded: SEEDED,
intervalMs: 10,
fetchImpl: fetchImpl as unknown as typeof fetch,
log: () => {},
});

watcher.start();
await settle(120);
const outcome = await watcher.stop();

expect(fetchImpl.mock.calls.length).toBeGreaterThanOrEqual(3);
// The blob still got stored -- the whole point.
expect(outcome === "stored" || outcome === "unchanged").toBe(true);
});

it("retries the final flush, so a refresh in the turn's last seconds is not lost to one bad POST", async () => {
const home = homeWithCredentials(REFRESHED);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 502 } as Response)
.mockResolvedValue({ ok: true, status: 200 } as Response);
const watcher = createCredentialsWritebackWatcher({
homeDir: home,
url: "https://gateway.example/refresh",
token: "grant-abc",
seeded: SEEDED,
fetchImpl: fetchImpl as unknown as typeof fetch,
flushAttempts: 3,
flushRetryDelayMs: 5,
log: () => {},
});

// Never started: this is purely the end-of-turn path.
const outcome = await watcher.stop();

expect(outcome).toBe("stored");
expect(fetchImpl).toHaveBeenCalledTimes(2);
});

it("says loudly when it gives up, because the link is dead at that point", async () => {
const logs: string[] = [];
const watcher = createCredentialsWritebackWatcher({
homeDir: homeWithCredentials(REFRESHED),
url: "https://gateway.example/refresh",
token: "grant-abc",
seeded: SEEDED,
fetchImpl: vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response) as unknown as typeof fetch,
flushAttempts: 2,
flushRetryDelayMs: 1,
log: (m) => logs.push(m),
});

const outcome = await watcher.stop();

expect(outcome).toBe("failed");
expect(logs.join("\n")).toMatch(/GAVE UP/);
expect(logs.join("\n")).toMatch(/need re-linking/);
});

it("is inert when no grant was injected (a non-claude-remote run)", async () => {
const fetchImpl = vi.fn();
const watcher = createCredentialsWritebackWatcher({
homeDir: homeWithCredentials(REFRESHED),
url: "",
token: "",
seeded: SEEDED,
intervalMs: 5,
fetchImpl: fetchImpl as unknown as typeof fetch,
log: () => {},
});

watcher.start();
await settle(30);
expect(await watcher.stop()).toBe("disabled");
expect(fetchImpl).not.toHaveBeenCalled();
});
});
Loading