Compare commits

...
22 changed files with 2158 additions and 23 deletions

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Add access-only Claude CLI and Codex runtimes for EAS workers. ([#4330](https://github.com/expo/eas-cli/pull/4330) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
### 🧹 Chores
@@ -31,6 +31,23 @@ describe('BuildContext', () => {
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('exposes cancellation to a running agent process', () => {
const ctx = createTestBuildContext({});
expect(ctx.cancellationSignal.aborted).toBe(false);
ctx.cancel();
expect(ctx.cancellationSignal.aborted).toBe(true);
});
it('registers a provider token with the worker log filter', () => {
const registerSecret = jest.fn();
const ctx = createTestBuildContext({}, { registerSecret });
ctx.registerSecret('provider-access-token');
expect(registerSecret).toHaveBeenCalledWith('provider-access-token');
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -216,7 +233,10 @@ describe('BuildContext', () => {
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
function createTestBuildContext(
{ platform }: { platform?: Platform },
{ registerSecret }: { registerSecret?: (value: string) => void } = {}
): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
@@ -232,6 +252,7 @@ function createTestBuildContext({ platform }: { platform?: Platform }): BuildCon
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
registerSecret,
}
);
}
@@ -0,0 +1,120 @@
import { errors } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { z } from 'zod';
const AgentProviderRuntimeLeaseZ = z.discriminatedUnion('provider', [
z.strictObject({
provider: z.literal('anthropic'),
accessToken: z.string().min(1),
expiresAt: z.string().datetime(),
}),
z.strictObject({
provider: z.literal('openai'),
idToken: z.string().min(1),
accessToken: z.string().min(1),
accountId: z.string().min(1),
plan: z.string().nullable(),
expiresAt: z.string().datetime(),
accessTokenFingerprint: z.string().length(43),
}),
]);
const AgentProviderRuntimeLeaseResponseZ = z.strictObject({
data: AgentProviderRuntimeLeaseZ,
});
export type AgentProviderRuntimeLease = z.infer<typeof AgentProviderRuntimeLeaseZ>;
export type AgentProviderRuntimeLeaseRequest =
| { reason: 'startup' | 'proactive' }
| { reason: 'unauthorized'; previousAccessTokenFingerprint: string };
export class AgentAuthLeaseClient {
private readonly endpoint: string;
public constructor({
expoApiV2BaseUrl,
expoToken,
jobRunId,
registerSecret,
}: {
expoApiV2BaseUrl: string;
expoToken: string;
jobRunId: string;
registerSecret?: (value: string) => void;
}) {
this.endpoint = new URL(
`agent-job-runs/${jobRunId}/auth-lease`,
ensureTrailingSlash(expoApiV2BaseUrl)
).toString();
this.expoToken = expoToken;
this.registerSecret = registerSecret;
}
private readonly expoToken: string;
private readonly registerSecret?: (value: string) => void;
public async getLeaseAsync(
request: AgentProviderRuntimeLeaseRequest,
signal?: AbortSignal
): Promise<AgentProviderRuntimeLease> {
let response;
try {
response = await fetch(this.endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${this.expoToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(request),
signal,
});
} catch (error) {
throw new errors.SystemError(
'EAS could not request provider access for this agent job. Check the worker network connection and try the job again.',
{ cause: error }
);
}
if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_ACCESS_REJECTED',
'EAS could not provide provider access for this agent job. The job may have ended, or the provider connection may need to be reconnected. Reconnect the provider if needed, then start a new job.'
);
}
throw new errors.SystemError(
'EAS could not provide provider access because the API returned a server error. Try the job again. Contact Expo support if the problem continues.',
{ metadata: { status: response.status } }
);
}
let rawResponse: unknown;
try {
rawResponse = await response.json();
} catch (error) {
throw new errors.SystemError(
'EAS received an invalid provider access response. Try the job again. Contact Expo support if the problem continues.',
{ cause: error, metadata: { status: response.status } }
);
}
const parsedResponse = AgentProviderRuntimeLeaseResponseZ.safeParse(rawResponse);
if (!parsedResponse.success) {
throw new errors.SystemError(
'EAS received an unexpected provider access response. Try the job again. Contact Expo support if the problem continues.',
{ cause: parsedResponse.error, metadata: { status: response.status } }
);
}
const lease = parsedResponse.data.data;
this.registerSecret?.(lease.accessToken);
if (lease.provider === 'openai') {
this.registerSecret?.(lease.idToken);
}
return lease;
}
}
function ensureTrailingSlash(url: string): string {
return url.endsWith('/') ? url : `${url}/`;
}
@@ -0,0 +1,70 @@
import { errors } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import type { SpawnResult } from '@expo/turtle-spawn';
import { AgentAuthLeaseClient } from './AgentAuthLeaseClient';
import {
assertAgentExecutableVersionAsync,
assertLeaseCoversInvocation,
runBoundedAgentProcessAsync,
} from './processUtils';
export const CLAUDE_CODE_VERSION = '2.1.246';
export class ClaudeCliRuntime {
public constructor(
private readonly leaseClient: AgentAuthLeaseClient,
private readonly executable = 'claude'
) {}
public async runAsync({
args,
cwd,
env,
logger,
maximumInvocationSeconds,
signal,
}: {
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
maximumInvocationSeconds: number;
signal?: AbortSignal;
}): Promise<SpawnResult> {
if (args.includes('--bare')) {
throw new errors.UserError(
'EAS_AGENT_CLAUDE_BARE_UNSUPPORTED',
'Claude Code --bare mode is not supported for EAS agent jobs. Remove --bare and start a new job.'
);
}
await assertAgentExecutableVersionAsync({
executable: this.executable,
expectedVersion: CLAUDE_CODE_VERSION,
displayName: 'Claude Code',
});
const lease = await this.leaseClient.getLeaseAsync({ reason: 'startup' }, signal);
if (lease.provider !== 'anthropic') {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_MISMATCH',
'This agent job is bound to a different provider. Select a Claude connection and start a new job.'
);
}
assertLeaseCoversInvocation(lease.expiresAt, maximumInvocationSeconds);
const processEnv = { ...env };
delete processEnv.ANTHROPIC_API_KEY;
delete processEnv.ANTHROPIC_AUTH_TOKEN;
processEnv.CLAUDE_CODE_OAUTH_TOKEN = lease.accessToken;
return await runBoundedAgentProcessAsync({
command: this.executable,
args,
cwd,
env: processEnv,
logger,
maximumInvocationSeconds,
secrets: [lease.accessToken],
signal,
});
}
}
@@ -0,0 +1,683 @@
import { errors } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import {
type ChildProcessWithoutNullStreams,
spawn as spawnChildProcess,
} from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createInterface } from 'node:readline';
import { setTimeout as sleepAsync } from 'node:timers/promises';
import { z } from 'zod';
import { AgentAuthLeaseClient, type AgentProviderRuntimeLease } from './AgentAuthLeaseClient';
import { CODEX_VERSION } from './CodexExecRuntime';
import { assertAgentExecutableVersionAsync } from './processUtils';
const PROACTIVE_REFRESH_MARGIN_MS = 60 * 1000;
const HOST_REFRESH_TIMEOUT_MS = 8 * 1000;
const REQUEST_TIMEOUT_MS = 30 * 1000;
const TURN_INTERRUPT_GRACE_MS = 5 * 1000;
const JsonRpcMessageZ = z.looseObject({
id: z.union([z.string(), z.number()]).optional(),
method: z.string().optional(),
params: z.unknown().optional(),
result: z.unknown().optional(),
error: z.unknown().optional(),
});
const RefreshParamsZ = z.strictObject({
reason: z.literal('unauthorized'),
previousAccountId: z.string().nullable().optional(),
});
const ThreadStartResponseZ = z.looseObject({
thread: z.looseObject({ id: z.string().min(1) }),
});
const TurnStartResponseZ = z.looseObject({
turn: z.looseObject({ id: z.string().min(1) }),
});
const AgentMessageItemZ = z.looseObject({
type: z.literal('agentMessage'),
text: z.string(),
});
const TurnCompletedParamsZ = z.looseObject({
threadId: z.string().min(1),
turn: z.looseObject({
id: z.string().min(1),
status: z.enum(['completed', 'failed', 'interrupted']),
items: z.array(z.unknown()).optional(),
}),
});
const ItemCompletedParamsZ = z.looseObject({
threadId: z.string().min(1),
turnId: z.string().min(1),
item: z.unknown(),
});
type OpenAILease = Extract<AgentProviderRuntimeLease, { provider: 'openai' }>;
export type CodexTurnResult = {
threadId: string;
turnId: string;
finalResponse: string;
};
export class CodexAppServerRuntime {
public constructor(
private readonly leaseClient: AgentAuthLeaseClient,
private readonly executable = 'codex',
private readonly spawnProcess: typeof spawnChildProcess = spawnChildProcess
) {}
public async startAsync({
cwd,
env,
logger,
signal,
}: {
cwd?: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<CodexAppServerSession> {
await assertAgentExecutableVersionAsync({
executable: this.executable,
expectedVersion: CODEX_VERSION,
displayName: 'Codex CLI',
});
const lease = await this.leaseClient.getLeaseAsync({ reason: 'startup' }, signal);
if (lease.provider !== 'openai') {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_MISMATCH',
'This agent job is bound to a different provider. Select a Codex connection and start a new job.'
);
}
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-codex-app-server-'));
const processEnv: NodeJS.ProcessEnv = { ...env, CODEX_HOME: codexHome };
delete processEnv.OPENAI_API_KEY;
const child = this.spawnProcess(this.executable, ['app-server'], {
cwd,
env: processEnv,
detached: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
const session = new CodexAppServerSession({
child,
codexHome,
initialLease: lease,
leaseClient: this.leaseClient,
logger,
signal,
});
try {
await session.initializeAsync();
return session;
} catch (error) {
await session.stopAsync();
if (signal?.aborted) {
throw new errors.UserError(
'EAS_AGENT_JOB_CANCELLED',
'The agent job was cancelled while Codex app-server was starting.'
);
}
throw new errors.UserError(
'EAS_AGENT_CODEX_PROTOCOL_UNSUPPORTED',
'The installed Codex app-server does not support EAS external authentication. Update the worker runtime and start a new job.',
{ cause: error }
);
}
}
}
export class CodexAppServerSession {
private currentLease: OpenAILease;
private nextRequestId = 1;
private readonly pendingRequests = new Map<
string | number,
{
resolve: (result: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
>();
private refreshOperation: Promise<OpenAILease> | null = null;
private proactiveRefreshTimer: NodeJS.Timeout | null = null;
private cleanupPromise: Promise<void> | null = null;
private fatalError: Error | null = null;
private hasReportedStderr = false;
private stopped = false;
private readonly sessionAbortController = new AbortController();
private readonly completedTurns = new Map<string, z.infer<typeof TurnCompletedParamsZ>>();
private readonly finalResponses = new Map<string, string>();
private readonly interruptedTurnCleanupTimers = new Map<string, NodeJS.Timeout>();
private readonly turnWaiters = new Map<
string,
{
threadId: string;
resolve: (result: z.infer<typeof TurnCompletedParamsZ>) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
>();
public constructor(
private readonly options: {
child: ChildProcessWithoutNullStreams;
codexHome: string;
initialLease: OpenAILease;
leaseClient: AgentAuthLeaseClient;
logger: bunyan;
signal?: AbortSignal;
}
) {
this.currentLease = options.initialLease;
const lines = createInterface({ input: options.child.stdout });
lines.on('line', line => {
this.handleLine(line);
});
options.child.on('exit', () => {
this.stopped = true;
const error =
this.fatalError ?? new Error('Codex app-server stopped before the request completed.');
this.rejectPendingRequests(error);
this.rejectTurnWaiters(error);
void this.stopAsync();
});
options.child.on('error', error => {
this.fail(new Error('Codex app-server could not start.', { cause: error }));
});
options.child.stdin.on('error', error => {
this.fail(new Error('EAS could not send a request to Codex app-server.', { cause: error }));
});
options.child.stderr.on('data', () => {
if (this.hasReportedStderr) {
return;
}
this.hasReportedStderr = true;
options.logger.warn(
'Codex app-server reported an error. Its output was hidden to protect provider credentials.'
);
});
if (options.signal?.aborted) {
void this.stopAsync();
} else {
options.signal?.addEventListener('abort', this.handleAbort, { once: true });
}
}
public async initializeAsync(): Promise<void> {
await this.requestAsync('initialize', {
clientInfo: { name: 'eas-agent-runtime', title: 'EAS Agent Runtime', version: '1.0.0' },
capabilities: { experimentalApi: true },
});
this.notify('initialized');
await this.loginAsync(this.currentLease);
this.scheduleProactiveRefresh();
}
public async startThreadAsync({ cwd }: { cwd?: string }): Promise<{ id: string }> {
const response = ThreadStartResponseZ.safeParse(
await this.requestAsync('thread/start', {
...(cwd ? { cwd } : {}),
approvalPolicy: 'never',
sandbox: 'workspace-write',
})
);
if (!response.success) {
throw new errors.UserError(
'EAS_AGENT_CODEX_PROTOCOL_UNSUPPORTED',
'Codex app-server returned an invalid thread response. Update the worker runtime and start a new job.',
{ cause: response.error }
);
}
return { id: response.data.thread.id };
}
public async runTurnAsync({
threadId,
prompt,
maximumInvocationSeconds,
}: {
threadId: string;
prompt: string;
maximumInvocationSeconds: number;
}): Promise<CodexTurnResult> {
const response = TurnStartResponseZ.safeParse(
await this.requestAsync('turn/start', {
threadId,
input: [{ type: 'text', text: prompt, textElements: [] }],
})
);
if (!response.success) {
throw new errors.UserError(
'EAS_AGENT_CODEX_PROTOCOL_UNSUPPORTED',
'Codex app-server returned an invalid turn response. Update the worker runtime and start a new job.',
{ cause: response.error }
);
}
const turnId = response.data.turn.id;
const completion = await this.waitForTurnAsync({
threadId,
turnId,
maximumInvocationSeconds,
});
switch (completion.turn.status) {
case 'completed':
return {
threadId,
turnId,
finalResponse:
this.finalResponses.get(turnId) ??
getFinalAgentResponse(completion.turn.items ?? []) ??
'',
};
case 'failed':
throw new errors.UserError(
'EAS_AGENT_CODEX_TURN_FAILED',
'Codex could not complete the agent turn. Review the job log and try again.'
);
case 'interrupted':
throw new errors.UserError(
'EAS_AGENT_CODEX_TURN_INTERRUPTED',
'The Codex agent turn was interrupted before it completed. Start the job again.'
);
}
}
private async requestAsync(method: string, params?: unknown): Promise<unknown> {
if (this.fatalError) {
throw this.fatalError;
}
if (this.stopped) {
throw new Error('Codex app-server is stopped.');
}
const id = this.nextRequestId++;
const result = new Promise<unknown>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Codex app-server did not answer ${method} in time.`));
}, REQUEST_TIMEOUT_MS);
this.pendingRequests.set(id, { resolve, reject, timeout });
});
this.write({ id, method, ...(params === undefined ? {} : { params }) });
return await result;
}
private notify(method: string, params?: unknown): void {
this.write({ method, ...(params === undefined ? {} : { params }) });
}
public async stopAsync(): Promise<void> {
const wasStopped = this.stopped;
this.stopped = true;
this.sessionAbortController.abort();
if (this.proactiveRefreshTimer) {
clearTimeout(this.proactiveRefreshTimer);
this.proactiveRefreshTimer = null;
}
for (const timeout of this.interruptedTurnCleanupTimers.values()) {
clearTimeout(timeout);
}
this.interruptedTurnCleanupTimers.clear();
this.options.signal?.removeEventListener('abort', this.handleAbort);
if (wasStopped) {
await this.cleanUpAsync();
return;
}
await stopProcessGroupAsync(this.options.child);
this.rejectPendingRequests(new Error('Codex app-server was stopped.'));
this.rejectTurnWaiters(new Error('Codex app-server was stopped.'));
await this.cleanUpAsync();
}
private readonly handleAbort = (): void => {
void this.stopAsync();
};
private handleLine(line: string): void {
const parsedMessage = JsonRpcMessageZ.safeParse(parseJson(line));
if (!parsedMessage.success) {
this.fail(new Error('Codex app-server returned an invalid protocol message.'));
return;
}
const message = parsedMessage.data;
if (message.method === 'account/chatgptAuthTokens/refresh' && message.id !== undefined) {
void this.handleUnauthorizedRefreshAsync(message.id, message.params);
return;
}
if (message.method === 'turn/completed') {
this.handleTurnCompleted(message.params);
return;
}
if (message.method === 'item/completed') {
this.handleItemCompleted(message.params);
return;
}
if (message.method && message.id !== undefined) {
this.write({
id: message.id,
error: { code: -32601, message: 'This EAS agent runtime does not support the request.' },
});
this.fail(new Error('Codex app-server requested an unsupported host capability.'));
return;
}
if (message.id === undefined || message.method) {
return;
}
const pending = this.pendingRequests.get(message.id);
if (!pending) {
return;
}
this.pendingRequests.delete(message.id);
clearTimeout(pending.timeout);
if (message.error !== undefined) {
pending.reject(new Error('Codex app-server rejected an EAS runtime request.'));
} else {
pending.resolve(message.result);
}
}
private async handleUnauthorizedRefreshAsync(
requestId: string | number,
rawParams: unknown
): Promise<void> {
const params = RefreshParamsZ.safeParse(rawParams);
if (
!params.success ||
(params.data.previousAccountId &&
params.data.previousAccountId !== this.currentLease.accountId)
) {
this.writeRefreshError(requestId);
return;
}
const timeoutController = new AbortController();
const timeout = setTimeout(() => timeoutController.abort(), HOST_REFRESH_TIMEOUT_MS);
try {
const lease = await this.refreshLeaseAsync(
'unauthorized',
/* applyToServer */ false,
timeoutController.signal
);
this.write({
id: requestId,
result: {
accessToken: lease.accessToken,
chatgptAccountId: lease.accountId,
chatgptPlanType: lease.plan,
},
});
} catch {
this.writeRefreshError(requestId);
} finally {
clearTimeout(timeout);
}
}
private writeRefreshError(requestId: string | number): void {
this.write({
id: requestId,
error: { code: -32000, message: 'EAS could not refresh provider access.' },
});
}
private handleItemCompleted(rawParams: unknown): void {
const params = ItemCompletedParamsZ.safeParse(rawParams);
if (!params.success) {
return;
}
const item = AgentMessageItemZ.safeParse(params.data.item);
if (item.success) {
this.finalResponses.set(params.data.turnId, item.data.text);
}
}
private handleTurnCompleted(rawParams: unknown): void {
const params = TurnCompletedParamsZ.safeParse(rawParams);
if (!params.success) {
this.fail(new Error('Codex app-server returned an invalid turn completion.'));
return;
}
const waiter = this.turnWaiters.get(params.data.turn.id);
const cleanupTimer = this.interruptedTurnCleanupTimers.get(params.data.turn.id);
if (cleanupTimer) {
clearTimeout(cleanupTimer);
this.interruptedTurnCleanupTimers.delete(params.data.turn.id);
}
if (!waiter) {
this.completedTurns.set(params.data.turn.id, params.data);
if (this.completedTurns.size > 20) {
this.completedTurns.delete(this.completedTurns.keys().next().value!);
}
return;
}
if (waiter.threadId !== params.data.threadId) {
return;
}
this.turnWaiters.delete(params.data.turn.id);
clearTimeout(waiter.timeout);
waiter.resolve(params.data);
}
private async waitForTurnAsync({
threadId,
turnId,
maximumInvocationSeconds,
}: {
threadId: string;
turnId: string;
maximumInvocationSeconds: number;
}): Promise<z.infer<typeof TurnCompletedParamsZ>> {
const completed = this.completedTurns.get(turnId);
if (completed?.threadId === threadId) {
this.completedTurns.delete(turnId);
return completed;
}
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.turnWaiters.delete(turnId);
void this.interruptTimedOutTurnAsync(threadId, turnId);
reject(
new errors.UserError(
'EAS_AGENT_INVOCATION_TIMEOUT',
'The Codex agent turn exceeded the job time limit and was stopped. Reduce the task scope and try again.'
)
);
}, maximumInvocationSeconds * 1000);
this.turnWaiters.set(turnId, { threadId, resolve, reject, timeout });
});
}
private async interruptTimedOutTurnAsync(threadId: string, turnId: string): Promise<void> {
const timeoutController = new AbortController();
const timeout = setTimeout(() => timeoutController.abort(), TURN_INTERRUPT_GRACE_MS);
try {
await Promise.race([
this.requestAsync('turn/interrupt', { threadId, turnId }),
new Promise((_, reject) => {
timeoutController.signal.addEventListener(
'abort',
() => reject(new Error('Codex app-server did not interrupt the turn in time.')),
{ once: true }
);
}),
]);
this.interruptedTurnCleanupTimers.set(
turnId,
setTimeout(() => {
this.interruptedTurnCleanupTimers.delete(turnId);
void this.stopAsync();
}, TURN_INTERRUPT_GRACE_MS)
);
} catch {
await this.stopAsync();
} finally {
clearTimeout(timeout);
}
}
private async refreshLeaseAsync(
reason: 'proactive' | 'unauthorized',
applyToServer: boolean,
signal?: AbortSignal
): Promise<OpenAILease> {
if (!this.refreshOperation) {
const operation = async () => {
const combinedSignal = signal
? AbortSignal.any([signal, this.sessionAbortController.signal])
: this.sessionAbortController.signal;
const lease = await this.options.leaseClient.getLeaseAsync(
reason === 'unauthorized'
? {
reason,
previousAccessTokenFingerprint: this.currentLease.accessTokenFingerprint,
}
: { reason },
combinedSignal
);
if (lease.provider !== 'openai' || lease.accountId !== this.currentLease.accountId) {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_IDENTITY_CHANGED',
'The provider connection changed to another ChatGPT account during this job. Start a new job with the intended connection.'
);
}
if (this.stopped) {
throw new Error('Codex app-server stopped while provider access was refreshing.');
}
if (new Date(lease.expiresAt).getTime() - Date.now() <= PROACTIVE_REFRESH_MARGIN_MS) {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_LEASE_TOO_SHORT',
'The provider access lease is too short for Codex app-server. Start a new job after reconnecting the provider.'
);
}
if (applyToServer) {
await this.loginAsync(lease);
}
this.currentLease = lease;
this.scheduleProactiveRefresh();
return lease;
};
this.refreshOperation = operation().finally(() => {
this.refreshOperation = null;
});
}
return await this.refreshOperation;
}
private scheduleProactiveRefresh(): void {
if (this.stopped) {
return;
}
if (this.proactiveRefreshTimer) {
clearTimeout(this.proactiveRefreshTimer);
}
const delay = Math.max(
0,
new Date(this.currentLease.expiresAt).getTime() - Date.now() - PROACTIVE_REFRESH_MARGIN_MS
);
this.proactiveRefreshTimer = setTimeout(() => {
void this.refreshLeaseAsync('proactive', /* applyToServer */ true).catch(error => {
this.fail(
error instanceof Error
? error
: new Error('Codex app-server provider access refresh failed.')
);
});
}, delay);
}
private async loginAsync(lease: OpenAILease): Promise<void> {
await this.requestAsync('account/login/start', {
type: 'chatgptAuthTokens',
accessToken: lease.accessToken,
chatgptAccountId: lease.accountId,
chatgptPlanType: lease.plan,
});
}
private write(message: Record<string, unknown>): void {
if (!this.stopped) {
this.options.child.stdin.write(`${JSON.stringify(message)}\n`);
}
}
private fail(error: Error): void {
this.fatalError = error;
this.rejectPendingRequests(error);
void this.stopAsync();
}
private rejectPendingRequests(error: Error): void {
for (const pending of this.pendingRequests.values()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRequests.clear();
}
private rejectTurnWaiters(error: Error): void {
for (const waiter of this.turnWaiters.values()) {
clearTimeout(waiter.timeout);
waiter.reject(error);
}
this.turnWaiters.clear();
}
private async cleanUpAsync(): Promise<void> {
if (!this.cleanupPromise) {
this.cleanupPromise = fs.rm(this.options.codexHome, { recursive: true, force: true });
}
await this.cleanupPromise;
}
}
function parseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function getFinalAgentResponse(items: unknown[]): string | null {
for (let index = items.length - 1; index >= 0; index--) {
const item = AgentMessageItemZ.safeParse(items[index]);
if (item.success) {
return item.data.text;
}
}
return null;
}
async function stopProcessGroupAsync(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null) {
return;
}
if (child.pid === undefined) {
child.kill('SIGTERM');
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
const deadline = Date.now() + 5_000;
while (Date.now() < deadline && child.exitCode === null) {
await sleepAsync(100);
}
if (child.exitCode !== null) {
return;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
child.kill('SIGKILL');
}
}
@@ -0,0 +1,87 @@
import { errors } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import type { SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { AgentAuthLeaseClient } from './AgentAuthLeaseClient';
import {
assertAgentExecutableVersionAsync,
assertLeaseCoversInvocation,
runBoundedAgentProcessAsync,
} from './processUtils';
export const CODEX_VERSION = '0.149.1';
const NON_REFRESHING_SENTINEL = 'eas-access-only-lease';
export class CodexExecRuntime {
public constructor(
private readonly leaseClient: AgentAuthLeaseClient,
private readonly executable = 'codex'
) {}
public async runAsync({
args,
cwd,
env,
logger,
maximumInvocationSeconds,
signal,
}: {
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
maximumInvocationSeconds: number;
signal?: AbortSignal;
}): Promise<SpawnResult> {
await assertAgentExecutableVersionAsync({
executable: this.executable,
expectedVersion: CODEX_VERSION,
displayName: 'Codex CLI',
});
const lease = await this.leaseClient.getLeaseAsync({ reason: 'startup' }, signal);
if (lease.provider !== 'openai') {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_MISMATCH',
'This agent job is bound to a different provider. Select a Codex connection and start a new job.'
);
}
assertLeaseCoversInvocation(lease.expiresAt, maximumInvocationSeconds);
const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-codex-'));
try {
await fs.writeFile(
path.join(codexHome, 'auth.json'),
JSON.stringify({
auth_mode: 'chatgpt',
OPENAI_API_KEY: null,
tokens: {
id_token: lease.idToken,
access_token: lease.accessToken,
refresh_token: NON_REFRESHING_SENTINEL,
account_id: lease.accountId,
},
last_refresh: new Date().toISOString(),
}),
{ mode: 0o600 }
);
const processEnv: NodeJS.ProcessEnv = { ...env, CODEX_HOME: codexHome };
delete processEnv.OPENAI_API_KEY;
return await runBoundedAgentProcessAsync({
command: this.executable,
args,
cwd,
env: processEnv,
logger,
maximumInvocationSeconds,
secrets: [lease.idToken, lease.accessToken],
signal,
});
} finally {
await fs.rm(codexHome, { recursive: true, force: true });
}
}
}
@@ -0,0 +1,74 @@
import type { bunyan } from '@expo/logger';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { AgentAuthLeaseClient } from '../AgentAuthLeaseClient';
import { CLAUDE_CODE_VERSION } from '../ClaudeCliRuntime';
import { CodexAppServerRuntime } from '../CodexAppServerRuntime';
import { CODEX_VERSION } from '../CodexExecRuntime';
const execFileAsync = promisify(execFile);
const contractTest = process.env.EAS_AGENT_RUNTIME_CONTRACT_TESTS === '1' ? test : test.skip;
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('pinned agent runtime contracts', () => {
contractTest('Claude Code accepts an access token without saved credentials', async () => {
const result = await execFileAsync('claude', ['auth', 'status', '--json'], {
env: {
...process.env,
CLAUDE_CODE_OAUTH_TOKEN: 'contract-test-access-token',
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: '',
},
timeout: 10_000,
});
const status = JSON.parse(result.stdout);
expect(status).toMatchObject({
loggedIn: true,
authMethod: 'oauth_token',
apiProvider: 'firstParty',
});
expect(await executableVersionAsync('claude')).toContain(CLAUDE_CODE_VERSION);
});
contractTest('Codex app-server accepts experimental external ChatGPT auth', async () => {
const leaseClient = {
getLeaseAsync: jest.fn(async () => ({
provider: 'openai' as const,
idToken: 'contract-test-id-token',
accessToken: fakeJwt(),
accountId: 'contract-test-account',
plan: 'pro',
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
accessTokenFingerprint: 'f'.repeat(43),
})),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(leaseClient);
const session = await runtime.startAsync({
env: process.env,
logger: { warn: jest.fn() } as unknown as bunyan,
});
await session.stopAsync();
expect(await executableVersionAsync('codex')).toContain(CODEX_VERSION);
});
});
async function executableVersionAsync(executable: string): Promise<string> {
const result = await execFileAsync(executable, ['--version'], { timeout: 10_000 });
return `${result.stdout}${result.stderr}`;
}
function fakeJwt(): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(
JSON.stringify({ sub: 'contract-test', exp: Math.floor(Date.now() / 1000) + 600 })
).toString('base64url');
return `${header}.${payload}.contract-test-signature`;
}
@@ -0,0 +1,122 @@
import { errors } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { AgentAuthLeaseClient } from '../AgentAuthLeaseClient';
describe(AgentAuthLeaseClient, () => {
afterEach(() => {
jest.mocked(fetch).mockReset();
});
it('requests and parses an access-only OpenAI lease', async () => {
const lease = {
provider: 'openai',
idToken: 'id-token',
accessToken: 'access-token',
accountId: 'account-id',
plan: 'pro',
expiresAt: '2026-09-02T12:00:00.000Z',
accessTokenFingerprint: 'f'.repeat(43),
};
jest.mocked(fetch).mockResolvedValue(response({ data: lease }));
const registerSecret = jest.fn();
const client = new AgentAuthLeaseClient({
expoApiV2BaseUrl: 'https://api.expo.dev/v2/',
expoToken: 'expo-token',
jobRunId: 'job-run-id',
registerSecret,
});
await expect(client.getLeaseAsync({ reason: 'startup' })).resolves.toEqual(lease);
expect(fetch).toHaveBeenCalledWith(
'https://api.expo.dev/v2/agent-job-runs/job-run-id/auth-lease',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ authorization: 'Bearer expo-token' }),
body: JSON.stringify({ reason: 'startup' }),
})
);
expect(registerSecret).toHaveBeenCalledWith('access-token');
expect(registerSecret).toHaveBeenCalledWith('id-token');
});
it('requests an unauthorized refresh with only the access-token fingerprint', async () => {
const fingerprint = 'f'.repeat(43);
jest.mocked(fetch).mockResolvedValue(
response({
data: {
provider: 'openai',
idToken: 'new-id-token',
accessToken: 'new-access-token',
accountId: 'account-id',
plan: null,
expiresAt: '2026-09-02T12:00:00.000Z',
accessTokenFingerprint: 'n'.repeat(43),
},
})
);
const client = new AgentAuthLeaseClient({
expoApiV2BaseUrl: 'https://api.expo.dev/v2',
expoToken: 'expo-token',
jobRunId: 'job-run-id',
});
await client.getLeaseAsync({
reason: 'unauthorized',
previousAccessTokenFingerprint: fingerprint,
});
expect(jest.mocked(fetch).mock.calls[0][1]?.body).toBe(
JSON.stringify({
reason: 'unauthorized',
previousAccessTokenFingerprint: fingerprint,
})
);
});
it('does not include an API response body in a rejected lease error', async () => {
jest.mocked(fetch).mockResolvedValue(response({ error: 'secret-access-token' }, 403));
const client = new AgentAuthLeaseClient({
expoApiV2BaseUrl: 'https://api.expo.dev/v2/',
expoToken: 'expo-token',
jobRunId: 'job-run-id',
});
const error = await client.getLeaseAsync({ reason: 'startup' }).catch(caughtError => {
return caughtError;
});
expect(error).toBeInstanceOf(errors.UserError);
expect(String(error)).not.toContain('secret-access-token');
});
it('rejects a response that contains a refresh token', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
data: {
provider: 'anthropic',
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: '2026-09-02T12:00:00.000Z',
},
})
);
const client = new AgentAuthLeaseClient({
expoApiV2BaseUrl: 'https://api.expo.dev/v2/',
expoToken: 'expo-token',
jobRunId: 'job-run-id',
});
await expect(client.getLeaseAsync({ reason: 'startup' })).rejects.toBeInstanceOf(
errors.SystemError
);
});
});
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
@@ -0,0 +1,62 @@
import type { bunyan } from '@expo/logger';
import type { AgentAuthLeaseClient } from '../AgentAuthLeaseClient';
import { ClaudeCliRuntime } from '../ClaudeCliRuntime';
import { runBoundedAgentProcessAsync } from '../processUtils';
jest.mock('../processUtils', () => ({
...jest.requireActual('../processUtils'),
assertAgentExecutableVersionAsync: jest.fn(async () => {}),
runBoundedAgentProcessAsync: jest.fn(async () => ({ stdout: '', stderr: '' })),
}));
describe(ClaudeCliRuntime, () => {
it('runs Claude with an access token only and keeps resume arguments', async () => {
const leaseClient = {
getLeaseAsync: jest.fn(async () => ({
provider: 'anthropic' as const,
accessToken: 'claude-access-token',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})),
} as unknown as AgentAuthLeaseClient;
const runtime = new ClaudeCliRuntime(leaseClient, '/bin/claude');
await runtime.runAsync({
args: ['--resume', 'session-id', '-p', 'Continue'],
env: {
ANTHROPIC_API_KEY: 'api-key',
ANTHROPIC_AUTH_TOKEN: 'auth-token',
PATH: '/bin',
},
logger: {} as bunyan,
maximumInvocationSeconds: 300,
});
expect(runBoundedAgentProcessAsync).toHaveBeenCalledWith(
expect.objectContaining({
command: '/bin/claude',
args: ['--resume', 'session-id', '-p', 'Continue'],
env: {
PATH: '/bin',
CLAUDE_CODE_OAUTH_TOKEN: 'claude-access-token',
},
secrets: ['claude-access-token'],
})
);
});
it('rejects bare mode before requesting a lease', async () => {
const leaseClient = { getLeaseAsync: jest.fn() } as unknown as AgentAuthLeaseClient;
const runtime = new ClaudeCliRuntime(leaseClient);
await expect(
runtime.runAsync({
args: ['--bare'],
env: {},
logger: {} as bunyan,
maximumInvocationSeconds: 300,
})
).rejects.toThrow('--bare mode is not supported');
expect(leaseClient.getLeaseAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,504 @@
import type { bunyan } from '@expo/logger';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import type { AgentAuthLeaseClient } from '../AgentAuthLeaseClient';
import { CodexAppServerRuntime } from '../CodexAppServerRuntime';
jest.mock('../processUtils', () => ({
...jest.requireActual('../processUtils'),
assertAgentExecutableVersionAsync: jest.fn(async () => {}),
}));
function openAILease(overrides: Partial<Record<string, unknown>> = {}) {
return {
provider: 'openai' as const,
idToken: 'id-token',
accessToken: 'access-token',
accountId: 'account-id',
plan: 'pro',
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
accessTokenFingerprint: 'f'.repeat(43),
...overrides,
};
}
function createFakeAppServer(
respond: (message: Record<string, any>) => {
result?: unknown;
notifications?: Record<string, unknown>[];
} = () => ({ result: {} })
): {
child: ChildProcessWithoutNullStreams;
messages: Array<Record<string, any>>;
stdout: PassThrough;
} {
const stdin = new PassThrough();
const stdout = new PassThrough();
const stderr = new PassThrough();
const child = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr,
exitCode: null,
signalCode: null,
killed: false,
kill: jest.fn(() => true),
}) as unknown as ChildProcessWithoutNullStreams;
const messages: Array<Record<string, any>> = [];
child.stdin.on('data', chunk => {
for (const line of chunk.toString().trim().split('\n')) {
const message = JSON.parse(line);
messages.push(message);
if (message.id !== undefined) {
const response = respond(message);
stdout.write(`${JSON.stringify({ id: message.id, result: response.result ?? {} })}\n`);
for (const notification of response.notifications ?? []) {
stdout.write(`${JSON.stringify(notification)}\n`);
}
}
}
});
return { child, messages, stdout };
}
describe(CodexAppServerRuntime, () => {
afterEach(() => {
jest.useRealTimers();
});
it('initializes experimental external auth without persisting auth.json', async () => {
const { child, messages } = createFakeAppServer();
const leaseClient = {
getLeaseAsync: jest.fn(async () => openAILease()),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
expect(messages[0]).toMatchObject({
method: 'initialize',
params: { capabilities: { experimentalApi: true } },
});
expect(messages[1]).toEqual({ method: 'initialized' });
expect(messages[2]).toMatchObject({
method: 'account/login/start',
params: {
type: 'chatgptAuthTokens',
accessToken: 'access-token',
chatgptAccountId: 'account-id',
chatgptPlanType: 'pro',
},
});
const codexHome = (runtime as any).spawnProcess.mock.calls[0][2].env.CODEX_HOME;
await expect(fs.stat(`${codexHome}/auth.json`)).rejects.toThrow();
await session.stopAsync();
await expect(fs.stat(codexHome)).rejects.toThrow();
});
it('runs a thread and resolves after its matching turn completes', async () => {
const { child, messages } = createFakeAppServer(message => {
switch (message.method) {
case 'thread/start':
return { result: { thread: { id: 'thread-1' } } };
case 'turn/start':
return {
result: { turn: { id: 'turn-1' } },
notifications: [
{
method: 'item/completed',
params: {
threadId: 'thread-1',
turnId: 'turn-1',
item: { type: 'agentMessage', text: 'Finished task' },
},
},
{
method: 'turn/completed',
params: {
threadId: 'thread-other',
turn: { id: 'turn-1', status: 'completed', items: [] },
},
},
{
method: 'turn/completed',
params: {
threadId: 'thread-1',
turn: { id: 'turn-1', status: 'completed', items: [] },
},
},
],
};
default:
return { result: {} };
}
});
const leaseClient = {
getLeaseAsync: jest.fn(async () => openAILease()),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
const thread = await session.startThreadAsync({ cwd: '/working-directory' });
const result = await session.runTurnAsync({
threadId: thread.id,
prompt: 'Do the task',
maximumInvocationSeconds: 60,
});
expect(messages).toContainEqual(
expect.objectContaining({
method: 'thread/start',
params: {
cwd: '/working-directory',
approvalPolicy: 'never',
sandbox: 'workspace-write',
},
})
);
expect(messages).toContainEqual(
expect.objectContaining({
method: 'turn/start',
params: {
threadId: 'thread-1',
input: [{ type: 'text', text: 'Do the task', textElements: [] }],
},
})
);
expect(result).toEqual({
threadId: 'thread-1',
turnId: 'turn-1',
finalResponse: 'Finished task',
});
await session.stopAsync();
});
it('rejects a failed turn without exposing its raw payload', async () => {
const { child } = createFakeAppServer(message => {
if (message.method === 'turn/start') {
return {
result: { turn: { id: 'turn-1' } },
notifications: [
{
method: 'turn/completed',
params: {
threadId: 'thread-1',
turn: {
id: 'turn-1',
status: 'failed',
error: { additionalDetails: 'secret provider response' },
},
},
},
],
};
}
return { result: {} };
});
const leaseClient = {
getLeaseAsync: jest.fn(async () => openAILease()),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
await expect(
session.runTurnAsync({
threadId: 'thread-1',
prompt: 'Do the task',
maximumInvocationSeconds: 60,
})
).rejects.toThrow('Codex could not complete the agent turn.');
await session.stopAsync();
});
it('interrupts a turn that exceeds its invocation limit', async () => {
jest.useFakeTimers();
const { child, messages } = createFakeAppServer(message => {
if (message.method === 'turn/start') {
return { result: { turn: { id: 'turn-1' } } };
}
return { result: {} };
});
const leaseClient = {
getLeaseAsync: jest.fn(async () => openAILease()),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
const turn = session.runTurnAsync({
threadId: 'thread-1',
prompt: 'Do the task',
maximumInvocationSeconds: 1,
});
const turnExpectation = expect(turn).rejects.toThrow('exceeded the job time limit');
await jest.advanceTimersByTimeAsync(1_000);
await turnExpectation;
expect(messages).toContainEqual(
expect.objectContaining({
method: 'turn/interrupt',
params: { threadId: 'thread-1', turnId: 'turn-1' },
})
);
(child as any).exitCode = 0;
await session.stopAsync();
});
it('answers an unauthorized refresh with a new access-only lease', async () => {
const { child, messages, stdout } = createFakeAppServer();
const leaseClient = {
getLeaseAsync: jest
.fn()
.mockResolvedValueOnce(openAILease())
.mockResolvedValueOnce(
openAILease({
accessToken: 'new-access-token',
accessTokenFingerprint: 'n'.repeat(43),
})
),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
stdout.write(
`${JSON.stringify({
id: 77,
method: 'account/chatgptAuthTokens/refresh',
params: { reason: 'unauthorized', previousAccountId: 'account-id' },
})}\n`
);
await new Promise(resolve => setImmediate(resolve));
expect(leaseClient.getLeaseAsync).toHaveBeenLastCalledWith(
{ reason: 'unauthorized', previousAccessTokenFingerprint: 'f'.repeat(43) },
expect.any(AbortSignal)
);
expect(messages).toContainEqual({
id: 77,
result: {
accessToken: 'new-access-token',
chatgptAccountId: 'account-id',
chatgptPlanType: 'pro',
},
});
expect(JSON.stringify(messages)).not.toContain('id-token');
await session.stopAsync();
});
it('coalesces simultaneous unauthorized refresh requests', async () => {
const { child, messages, stdout } = createFakeAppServer();
let resolveRefresh!: (lease: ReturnType<typeof openAILease>) => void;
const refresh = new Promise<ReturnType<typeof openAILease>>(resolve => {
resolveRefresh = resolve;
});
const leaseClient = {
getLeaseAsync: jest.fn().mockResolvedValueOnce(openAILease()).mockReturnValueOnce(refresh),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
for (const id of [80, 81]) {
stdout.write(
`${JSON.stringify({
id,
method: 'account/chatgptAuthTokens/refresh',
params: { reason: 'unauthorized', previousAccountId: 'account-id' },
})}\n`
);
}
await new Promise(resolve => setImmediate(resolve));
resolveRefresh(
openAILease({ accessToken: 'new-access-token', accessTokenFingerprint: 'n'.repeat(43) })
);
await new Promise(resolve => setImmediate(resolve));
expect(leaseClient.getLeaseAsync).toHaveBeenCalledTimes(2);
expect(messages.filter(message => message.id === 80 || message.id === 81)).toHaveLength(2);
await session.stopAsync();
});
it('aborts an in-flight refresh when the job is cancelled', async () => {
const { child, stdout } = createFakeAppServer();
let refreshSignal: AbortSignal | undefined;
const leaseClient = {
getLeaseAsync: jest
.fn()
.mockResolvedValueOnce(openAILease())
.mockImplementationOnce((_request, signal) => {
refreshSignal = signal;
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
}),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const cancellationController = new AbortController();
const session = await runtime.startAsync({
env: {},
logger: { warn: jest.fn() } as any,
signal: cancellationController.signal,
});
stdout.write(
`${JSON.stringify({
id: 82,
method: 'account/chatgptAuthTokens/refresh',
params: { reason: 'unauthorized', previousAccountId: 'account-id' },
})}\n`
);
await new Promise(resolve => setImmediate(resolve));
cancellationController.abort();
await new Promise(resolve => setImmediate(resolve));
expect(refreshSignal?.aborted).toBe(true);
await session.stopAsync();
});
it('answers unsupported host requests and stops the session', async () => {
const { child, messages, stdout } = createFakeAppServer();
const leaseClient = {
getLeaseAsync: jest.fn(async () => openAILease()),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
stdout.write(`${JSON.stringify({ id: 90, method: 'item/tool/requestUserInput' })}\n`);
await new Promise(resolve => setImmediate(resolve));
expect(messages).toContainEqual({
id: 90,
error: { code: -32601, message: 'This EAS agent runtime does not support the request.' },
});
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
await session.stopAsync();
});
it('rejects an identity change during refresh', async () => {
const { child, messages, stdout } = createFakeAppServer();
const leaseClient = {
getLeaseAsync: jest
.fn()
.mockResolvedValueOnce(openAILease())
.mockResolvedValueOnce(openAILease({ accountId: 'other-account-id' })),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
stdout.write(
`${JSON.stringify({
id: 78,
method: 'account/chatgptAuthTokens/refresh',
params: { reason: 'unauthorized', previousAccountId: 'account-id' },
})}\n`
);
await new Promise(resolve => setImmediate(resolve));
expect(messages).toContainEqual({
id: 78,
error: { code: -32000, message: 'EAS could not refresh provider access.' },
});
await session.stopAsync();
});
it('replaces the external token before it expires', async () => {
jest.useFakeTimers();
const { child, messages } = createFakeAppServer();
const leaseClient = {
getLeaseAsync: jest
.fn()
.mockResolvedValueOnce(
openAILease({ expiresAt: new Date(Date.now() + 61 * 1000).toISOString() })
)
.mockResolvedValueOnce(
openAILease({
accessToken: 'proactive-access-token',
accessTokenFingerprint: 'p'.repeat(43),
})
),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const session = await runtime.startAsync({ env: {}, logger: { warn: jest.fn() } as any });
await jest.advanceTimersByTimeAsync(1_100);
expect(leaseClient.getLeaseAsync).toHaveBeenLastCalledWith(
{ reason: 'proactive' },
expect.any(AbortSignal)
);
expect(messages).toContainEqual(
expect.objectContaining({
method: 'account/login/start',
params: expect.objectContaining({ accessToken: 'proactive-access-token' }),
})
);
await session.stopAsync();
});
it('stops the app-server when the job is cancelled', async () => {
const { child } = createFakeAppServer();
const leaseClient = {
getLeaseAsync: jest.fn(async () => openAILease()),
} as unknown as AgentAuthLeaseClient;
const runtime = new CodexAppServerRuntime(
leaseClient,
'/bin/codex',
jest.fn(() => child) as any
);
const cancellationController = new AbortController();
const session = await runtime.startAsync({
env: {},
logger: { warn: jest.fn() } as any,
signal: cancellationController.signal,
});
cancellationController.abort();
await new Promise(resolve => setImmediate(resolve));
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
await session.stopAsync();
});
});
@@ -0,0 +1,70 @@
import type { bunyan } from '@expo/logger';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AgentAuthLeaseClient } from '../AgentAuthLeaseClient';
import { CodexExecRuntime } from '../CodexExecRuntime';
import { runBoundedAgentProcessAsync } from '../processUtils';
jest.mock('../processUtils', () => ({
...jest.requireActual('../processUtils'),
assertAgentExecutableVersionAsync: jest.fn(async () => {}),
runBoundedAgentProcessAsync: jest.fn(),
}));
describe(CodexExecRuntime, () => {
it('uses a private access-only auth file and removes the isolated Codex home', async () => {
const leaseClient = {
getLeaseAsync: jest.fn(async () => ({
provider: 'openai' as const,
idToken: 'id-token',
accessToken: 'access-token',
accountId: 'account-id',
plan: 'pro',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
accessTokenFingerprint: 'f'.repeat(43),
})),
} as unknown as AgentAuthLeaseClient;
let codexHome: string | undefined;
jest.mocked(runBoundedAgentProcessAsync).mockImplementation(async ({ env }) => {
codexHome = env.CODEX_HOME;
expect(codexHome).toBeDefined();
const authPath = path.join(codexHome!, 'auth.json');
expect((await fs.stat(authPath)).mode & 0o777).toBe(0o600);
const auth = JSON.parse(await fs.readFile(authPath, 'utf8'));
expect(auth).toMatchObject({
auth_mode: 'chatgpt',
OPENAI_API_KEY: null,
tokens: {
id_token: 'id-token',
access_token: 'access-token',
refresh_token: 'eas-access-only-lease',
account_id: 'account-id',
},
});
expect(JSON.stringify(auth)).not.toContain('canonical-refresh-token');
return { stdout: '', stderr: '' } as any;
});
const runtime = new CodexExecRuntime(leaseClient, '/bin/codex');
await runtime.runAsync({
args: ['exec', 'Fix the tests'],
env: { OPENAI_API_KEY: 'api-key', PATH: '/bin' },
logger: {} as bunyan,
maximumInvocationSeconds: 300,
});
expect(runBoundedAgentProcessAsync).toHaveBeenCalledWith(
expect.objectContaining({
command: '/bin/codex',
args: ['exec', 'Fix the tests'],
env: expect.objectContaining({ PATH: '/bin', CODEX_HOME: expect.any(String) }),
secrets: ['id-token', 'access-token'],
})
);
expect(jest.mocked(runBoundedAgentProcessAsync).mock.calls[0][0].env.OPENAI_API_KEY).toBe(
undefined
);
await expect(fs.stat(codexHome!)).rejects.toThrow();
});
});
@@ -0,0 +1,66 @@
import { errors } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import {
SecretRedactingTransform,
assertAgentExecutableVersionAsync,
runBoundedAgentProcessAsync,
} from '../processUtils';
describe(SecretRedactingTransform, () => {
it('redacts secrets split between output chunks', async () => {
const transform = new SecretRedactingTransform(['access-token']);
let output = '';
transform.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
transform.write('before access-');
transform.write('token after');
transform.end();
await new Promise<void>((resolve, reject) => {
transform.on('end', resolve);
transform.on('error', reject);
});
expect(output).toBe('before ************ after');
expect(output).not.toContain('access-token');
});
});
describe(assertAgentExecutableVersionAsync, () => {
it('accepts the expected executable version', async () => {
await expect(
assertAgentExecutableVersionAsync({
executable: process.execPath,
expectedVersion: process.versions.node,
displayName: 'Node.js',
})
).resolves.toBeUndefined();
});
it('rejects a different version', async () => {
await expect(
assertAgentExecutableVersionAsync({
executable: process.execPath,
expectedVersion: '0.0.0',
displayName: 'Node.js',
})
).rejects.toBeInstanceOf(errors.UserError);
});
});
describe(runBoundedAgentProcessAsync, () => {
it('closes stdin for a non-interactive process', async () => {
await expect(
runBoundedAgentProcessAsync({
command: process.execPath,
args: ['-e', 'process.stdin.resume()'],
env: process.env,
logger: { info: jest.fn(), error: jest.fn() } as unknown as bunyan,
maximumInvocationSeconds: 5,
secrets: [],
})
).resolves.toBeDefined();
});
});
@@ -0,0 +1,5 @@
export { AgentAuthLeaseClient } from './AgentAuthLeaseClient';
export type { AgentProviderRuntimeLease } from './AgentAuthLeaseClient';
export { CLAUDE_CODE_VERSION, ClaudeCliRuntime } from './ClaudeCliRuntime';
export { CodexAppServerRuntime, CodexAppServerSession } from './CodexAppServerRuntime';
export { CODEX_VERSION, CodexExecRuntime } from './CodexExecRuntime';
@@ -0,0 +1,172 @@
import { errors } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import spawn, { type SpawnResult } from '@expo/turtle-spawn';
import { execFile } from 'node:child_process';
import { Transform } from 'node:stream';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export async function assertAgentExecutableVersionAsync({
executable,
expectedVersion,
displayName,
}: {
executable: string;
expectedVersion: string;
displayName: string;
}): Promise<void> {
let versionOutput: string;
try {
const result = await execFileAsync(executable, ['--version'], { timeout: 10_000 });
versionOutput = `${result.stdout}\n${result.stderr}`;
} catch (error) {
throw new errors.UserError(
'EAS_AGENT_RUNTIME_UNAVAILABLE',
`${displayName} is not available on this worker. Start a new job with a supported worker image.`,
{ cause: error }
);
}
const reportedVersions = versionOutput.split(/\s+/).map(part => part.replace(/^v/, ''));
if (!reportedVersions.includes(expectedVersion)) {
throw new errors.UserError(
'EAS_AGENT_RUNTIME_VERSION_UNSUPPORTED',
`${displayName} ${expectedVersion} is required for this agent job. Start a new job with a supported worker image.`
);
}
}
export async function runBoundedAgentProcessAsync({
command,
args,
cwd,
env,
logger,
maximumInvocationSeconds,
secrets,
signal,
}: {
command: string;
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
maximumInvocationSeconds: number;
secrets: string[];
signal?: AbortSignal;
}): Promise<SpawnResult> {
const abortController = new AbortController();
let timedOut = false;
const forwardAbort = (): void => {
abortController.abort(signal?.reason);
};
if (signal?.aborted) {
forwardAbort();
} else {
signal?.addEventListener('abort', forwardAbort, { once: true });
}
const timeout = setTimeout(() => {
timedOut = true;
abortController.abort();
}, maximumInvocationSeconds * 1000);
try {
const processPromise = spawn(command, args, {
cwd,
env,
signal: abortController.signal,
stdio: 'pipe',
});
processPromise.child.stdin?.end();
pipeRedactedOutput(processPromise.child.stdout, logger, 'info', secrets);
pipeRedactedOutput(processPromise.child.stderr, logger, 'error', secrets);
return await processPromise;
} catch (error) {
if (timedOut) {
throw new errors.UserError(
'EAS_AGENT_INVOCATION_TIMEOUT',
`The agent exceeded its ${maximumInvocationSeconds}-second runtime limit. Start a new job with a shorter task.`,
{ cause: error }
);
}
throw error;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', forwardAbort);
}
}
export function assertLeaseCoversInvocation(
expiresAt: string,
maximumInvocationSeconds: number
): void {
if (new Date(expiresAt).getTime() - Date.now() < maximumInvocationSeconds * 1000) {
throw new errors.UserError(
'EAS_AGENT_PROVIDER_LEASE_TOO_SHORT',
'The provider access token expires before this agent task can finish. Reconnect the provider and start a new job.'
);
}
}
function pipeRedactedOutput(
output: NodeJS.ReadableStream | null,
logger: bunyan,
level: 'info' | 'error',
secrets: string[]
): void {
if (!output) {
return;
}
const redactor = new SecretRedactingTransform(secrets);
redactor.on('data', (chunk: Buffer) => {
logger[level](chunk.toString());
});
output.pipe(redactor);
}
export class SecretRedactingTransform extends Transform {
private pending = '';
private readonly secrets: string[];
private readonly maximumSecretLength: number;
public constructor(secrets: string[]) {
super();
this.secrets = secrets.filter(secret => secret.length > 0);
this.maximumSecretLength = Math.max(1, ...this.secrets.map(secret => secret.length));
}
public override _transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: (error?: Error | null) => void
): void {
this.pending += chunk.toString();
let safeLength = Math.max(0, this.pending.length - this.maximumSecretLength + 1);
for (const secret of this.secrets) {
let index = this.pending.indexOf(secret);
while (index !== -1) {
if (index < safeLength && index + secret.length > safeLength) {
safeLength = index;
}
index = this.pending.indexOf(secret, index + 1);
}
}
if (safeLength > 0) {
this.push(redact(this.pending, this.secrets).slice(0, safeLength));
this.pending = this.pending.slice(safeLength);
}
callback();
}
public override _flush(callback: (error?: Error | null) => void): void {
this.push(redact(this.pending, this.secrets));
this.pending = '';
callback();
}
}
function redact(value: string, secrets: string[]): string {
return secrets.reduce((result, secret) => {
return result.replaceAll(secret, '*'.repeat(secret.length));
}, value);
}
+15
View File
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
registerSecret?: (value: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -87,6 +88,8 @@ export class BuildContext<TJob extends Job = Job> {
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
private readonly cancellationController = new AbortController();
private readonly registerSecretCallback?: (value: string) => void;
private _env: Env;
private _job: TJob;
@@ -112,6 +115,7 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.registerSecretCallback = options.registerSecret;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -157,6 +161,17 @@ export class BuildContext<TJob extends Job = Job> {
public get isLocal(): boolean {
return this._isLocal;
}
public get cancellationSignal(): AbortSignal {
return this.cancellationController.signal;
}
public cancel(): void {
this.cancellationController.abort();
}
public registerSecret(value: string): void {
this.registerSecretCallback?.(value);
}
/**
* Directory used to store executables used during regular (non-custom) builds.
*/
@@ -68,9 +68,11 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly buildContext: BuildContext<TJob>;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
this.buildContext = buildCtx;
this._env = buildCtx.env;
this.job = buildCtx.job;
this.metadata = buildCtx.metadata;
@@ -112,6 +114,18 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
return this._env;
}
public get apiV2BaseUrl(): string | undefined {
return this.expoApiV2BaseUrl;
}
public get cancellationSignal(): AbortSignal {
return this.buildContext.cancellationSignal;
}
public registerSecret(value: string): void {
this.buildContext.registerSecret(value);
}
// We omit steps, because CustomBuildContext does not have steps.
public staticContext(): Omit<StaticJobInterpolationContext, 'steps'> {
return {
+1
View File
@@ -35,3 +35,4 @@ export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
export * from './runtimeSettings';
export * from './agent-runtime';
@@ -135,6 +135,29 @@ describe('logger', () => {
expect(logs[1].msg).toBe('another ****** in base64 is ********************');
});
it('obfuscates a secret registered after the logger starts in every log sink', async () => {
const { logger, outputStream, logBuffer, registerSecret } =
await createBuildLoggerWithSecretsFilter({});
const logs: any[] = [];
outputStream.pipe(
new Writable({
objectMode: true,
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
logs.push(chunk);
callback(null, chunk);
},
})
);
registerSecret('dynamic-access-token');
logger.info({ source: 'stdout' }, 'token=dynamic-access-token');
await waitForStreamFlush();
expect(logs[0].msg).toBe('token=********************');
expect(logBuffer.getLogs()).toEqual(['token=********************']);
expect((logger as any).streams).toHaveLength(1);
});
it('adds logId to each log', async () => {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({});
@@ -117,6 +117,7 @@ describe('BuildService Datadog setup', () => {
cleanUp: jest.fn(async () => {}),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] } as any,
outputStream: {} as any,
registerSecret: jest.fn(),
});
});
+3
View File
@@ -26,6 +26,7 @@ export async function createBuildContext<TJob extends Job>({
projectId,
buildId,
buildLogger,
registerSecret,
}: {
job: TJob;
logBuffer: LogBuffer;
@@ -34,6 +35,7 @@ export async function createBuildContext<TJob extends Job>({
projectId: string;
buildId: string;
buildLogger: bunyan;
registerSecret?: (value: string) => void;
}): Promise<BuildContext<TJob>> {
const childLogger = buildLogger.child({ buildId });
await RuntimeSettings.loadAsync({
@@ -103,6 +105,7 @@ export async function createBuildContext<TJob extends Job>({
cacheManager: new GCSCacheManager(),
metadata,
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
registerSecret,
});
return ctx;
}
+38 -21
View File
@@ -56,14 +56,19 @@ export class WorkerLogBuffer extends Writable implements LogBuffer {
}
}
function createTransformStream(secrets: EnvironmentSecret[]): Transform {
function createTransformStream(secrets: EnvironmentSecret[]): {
transformStream: Transform;
registerSecret: (value: string) => void;
} {
const secretValues = secrets.map(({ value }) => value);
const secretList: string[] = [
...secretValues,
...secretValues.map(maybeStringBase64Decode).filter((i): i is string => !!i),
].filter(i => i.length > 1 && !simpleSecretsWhitelist.includes(i));
const secretList = new Set<string>(
[
...secretValues,
...secretValues.map(maybeStringBase64Decode).filter((i): i is string => !!i),
].filter(i => i.length > 1 && !simpleSecretsWhitelist.includes(i))
);
return new Transform({
const transformStream = new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform(_chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
@@ -79,7 +84,7 @@ function createTransformStream(secrets: EnvironmentSecret[]): Transform {
}
if (chunk && typeof chunk === 'object' && chunk.msg) {
const msgWithoutSecrets = secretList.reduce((acc: string, pattern: string): string => {
const msgWithoutSecrets = [...secretList].reduce((acc: string, pattern: string): string => {
return acc.replaceAll(pattern, '*'.repeat(pattern.length));
}, chunk.msg as string);
@@ -92,6 +97,14 @@ function createTransformStream(secrets: EnvironmentSecret[]): Transform {
}
},
});
return {
transformStream,
registerSecret: value => {
if (value.length > 1 && !simpleSecretsWhitelist.includes(value)) {
secretList.add(value);
}
},
};
}
function createDiscardStream(): Writable {
@@ -108,18 +121,26 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
cleanUp: () => Promise<void>;
logBuffer: WorkerLogBuffer;
outputStream: Readable;
registerSecret: (value: string) => void;
}> {
const buildLogger = defaultLogger.child({ phase: BuildPhase.UNKNOWN });
const transformStream = createTransformStream(secrets?.environmentSecrets ?? []);
const { transformStream, registerSecret } = createTransformStream(
secrets?.environmentSecrets ?? []
);
const discardStream = createDiscardStream();
transformStream.pipe(discardStream);
buildLogger.addStream({
type: 'raw',
stream: transformStream,
reemitErrorEvents: true,
level: buildLogger.level(),
const buildLogger = createLogger({
name: config.loggers.base.name,
level: LoggerLevel.INFO,
phase: BuildPhase.UNKNOWN,
streams: [
{
type: 'raw',
stream: transformStream,
reemitErrorEvents: true,
level: LoggerLevel.INFO,
},
],
});
let remoteLoggerStream: RemoteLoggerStream | null = null;
@@ -137,12 +158,7 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
}
const logBuffer = new WorkerLogBuffer(MAX_LINES_IN_BUFFER);
buildLogger.addStream({
type: 'raw',
stream: logBuffer,
reemitErrorEvents: true,
level: buildLogger.level(),
});
transformStream.pipe(logBuffer);
let httpLogStream: HttpLogStream | null = null;
if (config.loggers.http.baseUrl && secrets?.robotAccessToken) {
@@ -166,6 +182,7 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
},
outputStream: transformStream,
logBuffer,
registerSecret,
};
}
+4 -1
View File
@@ -164,9 +164,10 @@ export default class BuildService {
return;
}
this.buildContext?.cancel();
this.state.setAbortReason(reason);
const wasBuildCanceled = reason === LauncherMessage.AbortReason.CANCEL;
logger.info('Job aborted - ' + wasBuildCanceled ? 'canceled by user' : 'build timed out');
logger.info(`Job aborted - ${wasBuildCanceled ? 'canceled by user' : 'build timed out'}`);
await this.maybeUploadXCodeLogs();
this.state.finish(Worker.Status.ABORTED, {
applicationArchiveName: null,
@@ -275,6 +276,7 @@ export default class BuildService {
logger: buildLogger,
cleanUp,
logBuffer,
registerSecret,
} = await createBuildLoggerWithSecretsFilter(job.secrets ?? {});
this.logsCleanUp = cleanUp;
@@ -300,6 +302,7 @@ export default class BuildService {
projectId,
buildId: this.buildId,
buildLogger,
registerSecret,
});
this.buildContext = ctx;