Skip to content
Merged
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: 22 additions & 1 deletion agent-runner/src/agentcore-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ interface InvocationPayload {
cwd?: string;
/** OAuth token for Claude authentication. */
oauthToken?: string;
/** theme-03 F11: real OAuth expiry (ms since epoch) from the host registry. */
oauthExpiresAt?: number;
/** theme-03 F11: OAuth refresh token when the host has one. */
oauthRefreshToken?: string | null;
/** Path to a sentinel file — when it exists the agent stops. */
stopFile?: string;
}
Expand Down Expand Up @@ -126,6 +130,8 @@ async function* handleInvocation(
sdkSessionId,
allowedPrompts,
oauthToken,
oauthExpiresAt,
oauthRefreshToken,
stopFile,
} = payload;

Expand Down Expand Up @@ -155,7 +161,22 @@ async function* handleInvocation(
}

try {
await writeCredentialsFile(token);
// theme-03 F11: pass through real OAuth metadata from the host when
// available so the SDK does not see a fictitious 24h expiry. Prefer the
// per-invocation payload; fall back to process env vars for backwards
// compatibility with hosts that still propagate via env.
const rawEnvExpiresAt = process.env.CLAUDE_OAUTH_EXPIRES_AT;
const parsedEnvExpiresAt = rawEnvExpiresAt ? Number(rawEnvExpiresAt) : undefined;
const envExpiresAt =
parsedEnvExpiresAt && Number.isFinite(parsedEnvExpiresAt) && parsedEnvExpiresAt > 0
? parsedEnvExpiresAt
: undefined;
const expiresAt =
oauthExpiresAt && Number.isFinite(oauthExpiresAt) && oauthExpiresAt > 0
? oauthExpiresAt
: envExpiresAt;
const refreshToken = oauthRefreshToken ?? process.env.CLAUDE_OAUTH_REFRESH_TOKEN ?? null;
await writeCredentialsFile(token, { expiresAt, refreshToken });
} catch (credErr) {
yield evt('agent:error', {
error: `Credential setup failed: ${credErr instanceof Error ? credErr.message : String(credErr)}`,
Expand Down
2 changes: 0 additions & 2 deletions agent-runner/src/event-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,6 @@ export interface AgentPlanReadyData {
sdkSessionId: string;
/** Allowed bash prompts from ExitPlanMode */
allowedPrompts?: Array<{ tool: 'Bash'; prompt: string }>;
launchSwarm?: boolean;
teammateCount?: number;
skillCalls?: SkillCallRecord[];
}

Expand Down
54 changes: 49 additions & 5 deletions agent-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,39 @@ function handleTopologySystemMsg(
// Phase type
type AgentPhase = 'plan' | 'execute';

/**
* Parse CLAUDE_OAUTH_EXPIRES_AT env var.
*
* theme-03 F11: Accept a real token expiry from the host. When absent, fall
* back to a far-future sentinel so the SDK does not treat the token as expired
* but we avoid pretending to know a specific lifetime. The previous "+24h"
* default was a fiction — a token revoked externally still appeared valid to
* the SDK and surfaced as opaque 401s mid-stream.
*/
function parseOAuthExpiresAt(raw: string | undefined): number {
if (!raw) {
// Far-future sentinel (~year 5138). Signals "unknown expiry" to the SDK
// without masking real expirations when the host does supply them.
return 100_000_000_000_000;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
console.error(
`[agent-runner] CLAUDE_OAUTH_EXPIRES_AT is not a positive number ('${raw}'), using far-future default`
);
return 100_000_000_000_000;
}
return parsed;
}

// Configuration from environment (declared early for error handlers)
const config = {
oauthToken: process.env.CLAUDE_OAUTH_TOKEN,
// theme-03 F11: optional real OAuth metadata from the host. When absent the
// credentials file still gets written, but with a sentinel expiry rather
// than a fake Date.now()+24h value.
oauthExpiresAt: parseOAuthExpiresAt(process.env.CLAUDE_OAUTH_EXPIRES_AT),
oauthRefreshToken: process.env.CLAUDE_OAUTH_REFRESH_TOKEN ?? null,
taskId: process.env.AGENT_TASK_ID,
sessionId: process.env.AGENT_SESSION_ID,
prompt: process.env.AGENT_PROMPT,
Expand Down Expand Up @@ -372,9 +402,21 @@ function validateConfig(): void {
}

/**
* Write OAuth credentials to ~/.claude/.credentials.json
* Write OAuth credentials to $HOME/.claude/.credentials.json
* The Claude Agent SDK reads this file for authentication.
* OAuth tokens passed via ANTHROPIC_API_KEY env var are blocked by the API.
*
* theme-03 F11:
* - `homedir()` (which reads `process.env.HOME`) is used so that the host
* can place each concurrent agent-runner invocation under a distinct HOME
* (e.g. /tmp/agents/<taskId>) and avoid interleaved writes to a shared
* `/home/node/.claude/.credentials.json`.
* - `expiresAt` is read from the host via `CLAUDE_OAUTH_EXPIRES_AT` when
* available; otherwise a far-future sentinel is used. The previous
* `Date.now() + 24h` fiction caused revoked tokens to appear valid to the
* SDK for a day.
* - `refreshToken` is threaded through from `CLAUDE_OAUTH_REFRESH_TOKEN`
* when the host has one; otherwise null (SDK rejects empty string).
*/
async function writeCredentialsFile(): Promise<void> {
const home = homedir();
Expand All @@ -385,18 +427,20 @@ async function writeCredentialsFile(): Promise<void> {
console.error(`[agent-runner] Home directory: ${home}`);
console.error(`[agent-runner] Credentials path: ${credentialsFile}`);
console.error(`[agent-runner] Token received: ${config.oauthToken ? 'YES' : 'NONE'}`);
console.error(
`[agent-runner] Token expiresAt: ${process.env.CLAUDE_OAUTH_EXPIRES_AT ? 'from host' : 'sentinel (far-future)'}`
);
console.error(`[agent-runner] Refresh token: ${config.oauthRefreshToken ? 'provided' : 'none'}`);

if (!config.oauthToken) {
throw new Error('No OAuth token provided via CLAUDE_OAUTH_TOKEN environment variable');
}

// Use null instead of empty string for refreshToken - SDK may reject empty string
// expiresAt as milliseconds (matching SDK's expected format from `claude login`)
const credentials = {
claudeAiOauth: {
accessToken: config.oauthToken,
refreshToken: null,
expiresAt: Date.now() + 86400000, // 24h from now in milliseconds
refreshToken: config.oauthRefreshToken,
expiresAt: config.oauthExpiresAt,
scopes: ['user:inference', 'user:profile', 'user:sessions:claude_code'],
subscriptionType: 'max',
},
Expand Down
37 changes: 30 additions & 7 deletions agent-runner/src/shared-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,40 @@ import type { AgentFileChangedData } from './event-emitter.js';
// ---------------------------------------------------------------------------

/**
* Write OAuth credentials to ~/.claude/.credentials.json.
* Far-future sentinel (~year 5138) used when the host does not supply a real
* OAuth token expiry. theme-03 F11: the previous 24h fiction caused revoked
* tokens to appear valid to the SDK for up to a day.
*/
const OAUTH_EXPIRES_AT_SENTINEL = 100_000_000_000_000;

/**
* Options accepted by `writeCredentialsFile`. All fields except `oauthToken`
* are optional; when absent a safe default is substituted.
*/
export interface WriteCredentialsOptions {
/** Real OAuth token expiry (ms since epoch). Defaults to a far-future sentinel. */
expiresAt?: number;
/** OAuth refresh token from the host. Defaults to null (SDK rejects empty string). */
refreshToken?: string | null;
}

/**
* Write OAuth credentials to `$HOME/.claude/.credentials.json`.
* The Claude Agent SDK reads this file for authentication.
* OAuth tokens passed via ANTHROPIC_API_KEY env var are blocked by the API.
*
* SC-014: The credentials file is written with mode 0o600 (owner-read-only)
* to mitigate token exposure risk on shared filesystems.
*
* theme-03 F11: `expiresAt` and `refreshToken` are now threaded through from
* the host when available. `homedir()` (which reads `process.env.HOME`) is
* used so the host can place each concurrent agent-runner invocation under a
* distinct HOME and avoid interleaved writes to a shared credentials file.
*/
export async function writeCredentialsFile(oauthToken: string): Promise<void> {
export async function writeCredentialsFile(
oauthToken: string,
options: WriteCredentialsOptions = {}
): Promise<void> {
const home = homedir();
const claudeDir = join(home, '.claude');
const credentialsFile = join(claudeDir, '.credentials.json');
Expand All @@ -40,8 +66,8 @@ export async function writeCredentialsFile(oauthToken: string): Promise<void> {
const credentials = {
claudeAiOauth: {
accessToken: oauthToken,
refreshToken: null,
expiresAt: Date.now() + 86_400_000, // 24h
refreshToken: options.refreshToken ?? null,
expiresAt: options.expiresAt ?? OAUTH_EXPIRES_AT_SENTINEL,
scopes: ['user:inference', 'user:profile', 'user:sessions:claude_code'],
subscriptionType: 'max',
},
Expand Down Expand Up @@ -142,9 +168,6 @@ export function extractFileChange(
/** ExitPlanMode options captured from the tool call. */
export interface ExitPlanModeOptions {
allowedPrompts?: Array<{ tool: 'Bash'; prompt: string }>;
launchSwarm?: boolean;
teammateCount?: number;
pushToRemote?: boolean;
remoteSessionId?: string;
remoteSessionUrl?: string;
remoteSessionTitle?: string;
Expand Down
Loading