Compare commits

...
13 changed files with 578 additions and 75 deletions

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools][eas-build-job] Share typed agent events and event IDs alongside readable job logs for the agent run Activity view. ([#4600](https://github.com/expo/eas-cli/pull/4600) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { AgentRunEventZ, SystemError } from '@expo/eas-build-job';
import turtleSpawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { spawn } from 'node:child_process';
@@ -236,6 +236,23 @@ describe(runAgentAsync, () => {
agent.child.stdout.write(
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
);
agent.child.stdout.write(
JSON.stringify({
type: 'assistant',
message: {
content: [
{
type: 'tool_use',
id: 'toolu_2',
name: 'mcp__expo__sandbox_exec',
input: {
nested: [{ 'anthropic-access-token': 'expo-token anthropic-access-token' }],
},
},
],
},
}) + '\n'
);
agent.child.stderr.write('warning: anthropic-access-token\n');
agent.exit(0);
},
@@ -246,11 +263,43 @@ describe(runAgentAsync, () => {
const child = jest.mocked(logger.child);
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
expect(stdoutLogger.info.mock.calls).toEqual([
['Tool result: token: [redacted]'],
['Agent: Done.\nThe token was [redacted].'],
const infoEvents = stdoutLogger.info.mock.calls.map(([fields]: [{ agentEvent: unknown }]) =>
AgentRunEventZ.parse(fields.agentEvent)
);
const errorEvent = AgentRunEventZ.parse(stdoutLogger.error.mock.calls[0][0].agentEvent);
expect(infoEvents[0]).toMatchObject({
producer: 'claude-code',
content: [
{ type: 'tool_result', callId: 'toolu_1', text: 'token: [redacted]', isError: false },
],
});
expect(infoEvents[1].content).toEqual([
{ type: 'message', text: 'Done.\nThe token was [redacted].\n' },
]);
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
expect(errorEvent).toMatchObject({
content: [{ type: 'error', severity: 'error', message: 'boom' }],
});
expect(infoEvents[2]).toMatchObject({
content: [
{
type: 'tool_call',
callId: 'toolu_2',
name: 'mcp__expo__sandbox_exec',
arguments: { nested: [{ '[redacted]': '[redacted] [redacted]' }] },
},
],
});
const events = [...infoEvents, errorEvent];
expect(new Set(events.map(event => event.eventId)).size).toBe(events.length);
expect(new Set(events.map(event => event.invocationId)).size).toBe(1);
expect(JSON.stringify(events)).not.toContain('anthropic-access-token');
expect(JSON.stringify(events)).not.toContain('expo-token');
expect(stdoutLogger.info.mock.calls.map(([, text]: [unknown, string]) => text)).toEqual([
'Tool result: token: [redacted]',
'Agent: Done.\nThe token was [redacted].',
'Tool call: mcp__expo__sandbox_exec {"nested":[{"[redacted]":"[redacted] [redacted]"}]}',
]);
expect(stdoutLogger.error.mock.calls[0][1]).toBe('Agent failed: boom');
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
});
@@ -285,7 +334,16 @@ describe(runAgentAsync, () => {
expect.any(RangeError)
);
const [stdoutLogger] = jest.mocked(logger.child).mock.results.map(result => result.value);
expect(stdoutLogger.info.mock.calls).toEqual([['Agent: Still here.']]);
expect(stdoutLogger.info.mock.calls).toEqual([
[
{
agentEvent: expect.objectContaining({
content: [{ type: 'message', text: 'Still here.' }],
}),
},
'Agent: Still here.',
],
]);
});
it('runs Codex with its shell tool off and the credentials in a private home', async () => {
@@ -431,6 +489,16 @@ describe(runAgentAsync, () => {
if (invocations.length === 2) {
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
}
agent.child.stdout.write(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'mcp__expo__sandbox_list', input: {} },
],
},
}) + '\n'
);
agent.exit(invocations.length === 1 ? 1 : 0);
},
});
@@ -452,7 +520,12 @@ describe(runAgentAsync, () => {
[{ source: 'stdout' }],
[{ source: 'stderr' }],
]);
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
const [firstStdoutLogger, , resumedStdoutLogger, resumedStderrLogger] = child.mock.results.map(
result => result.value
);
expect(firstStdoutLogger.info.mock.calls[0][0].agentEvent.invocationId).not.toBe(
resumedStdoutLogger.info.mock.calls[0][0].agentEvent.invocationId
);
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
});
@@ -2,6 +2,7 @@ import { SystemError, UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import turtleSpawn from '@expo/turtle-spawn';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
@@ -51,7 +52,7 @@ export async function runAgentAsync({
}): Promise<void> {
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
const secrets = new Set<string>();
const secrets = new Set([expoToken]);
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
try {
@@ -190,6 +191,7 @@ async function runAgentProcessAsync({
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
const redact = (text: string): string =>
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
const invocationId = randomUUID();
const stdoutLogger = logger.child({ source: 'stdout' });
readline.createInterface({ input: child.stdout }).on('line', line => {
let messages: AgentLogMessage[];
@@ -200,8 +202,22 @@ async function runAgentProcessAsync({
Sentry.capture('Could not format a line of the agent output', error as Error);
return;
}
for (const { level, message } of messages) {
stdoutLogger[level](redact(message).trimEnd());
for (const { level, message, agentEvent } of messages) {
if (agentEvent) {
stdoutLogger[level](
{
agentEvent: {
version: 1,
eventId: randomUUID(),
invocationId,
...redactEvent(agentEvent, redact),
},
},
redact(message).trimEnd()
);
} else {
stdoutLogger[level](redact(message).trimEnd());
}
}
});
const stderrLogger = logger.child({ source: 'stderr' });
@@ -251,3 +267,20 @@ async function runAgentProcessAsync({
signal.throwIfAborted();
return { exitCode, exitSignal };
}
/** Redact each JSON string separately so quotes and backslashes in secrets stay valid JSON. */
function redactEvent(
value: Record<string, unknown>,
redact: (text: string) => string
): Record<string, unknown> {
const result = JSON.parse(JSON.stringify(value), (_key, item: unknown) => {
if (typeof item === 'string') {
return redact(item);
}
if (item && typeof item === 'object' && !Array.isArray(item)) {
return Object.fromEntries(Object.entries(item).map(([key, value]) => [redact(key), value]));
}
return item;
});
return result;
}
@@ -1,14 +1,15 @@
import { CLAUDE_CODE_CLI } from '../claude';
import { readFixtureLines } from './readFixture';
const format = CLAUDE_CODE_CLI.formatOutputLine;
const format = (line: string) =>
CLAUDE_CODE_CLI.formatOutputLine(line).map(({ level, message }) => ({ level, message }));
function formatFixture(name: string): ReturnType<typeof format> {
return readFixtureLines(name).flatMap(line => format(line));
}
describe('CLAUDE_CODE_CLI.formatOutputLine', () => {
it('renders a run with a tool call and skips thinking and token counts', () => {
it('renders tool activity and skips thinking and token updates', () => {
expect(formatFixture('claude-code-tool-call.jsonl')).toEqual([
{ level: 'info', message: 'MCP server expo is connected.' },
{ level: 'info', message: 'Tool call: mcp__expo__sandbox_exec {"command":"echo hi"}' },
@@ -99,18 +100,19 @@ describe('CLAUDE_CODE_CLI.formatOutputLine', () => {
).toEqual([{ level: 'error', message: 'Agent failed: error_during_execution' }]);
});
it('skips what it does not know without failing', () => {
expect(format(JSON.stringify({ type: 'rate_limit_event', rate_limit_info: {} }))).toEqual([]);
expect(format(JSON.stringify({ type: 'stream_event', event: {} }))).toEqual([]);
expect(format(JSON.stringify({ type: 'assistant', message: { content: 'text' } }))).toEqual([]);
expect(format(JSON.stringify({ type: 'user', message: { content: 'the prompt' } }))).toEqual(
[]
);
expect(format(JSON.stringify({ type: 'result', subtype: 'success', is_error: 'yes' }))).toEqual(
[]
);
it('skips events without readable activity', () => {
for (const event of [
{ type: 'rate_limit_event', rate_limit_info: {} },
{ type: 'stream_event', event: {} },
{ type: 'assistant', message: { content: 'text' } },
{ type: 'user', message: { content: 'the prompt' } },
{ type: 'result', subtype: 'success', is_error: 'yes' },
]) {
expect(format(JSON.stringify(event))).toEqual([]);
}
expect(format('null')).toEqual([]);
expect(format('[]')).toEqual([]);
expect(format('true')).toEqual([]);
});
it('keeps the known blocks of a message with an unknown block', () => {
@@ -1,14 +1,15 @@
import { CODEX_CLI } from '../codex';
import { readFixtureLines } from './readFixture';
const format = CODEX_CLI.formatOutputLine;
const format = (line: string) =>
CODEX_CLI.formatOutputLine(line).map(({ level, message }) => ({ level, message }));
function formatFixture(name: string): ReturnType<typeof format> {
return readFixtureLines(name).flatMap(line => format(line));
}
describe('CODEX_CLI.formatOutputLine', () => {
it('renders a run with two tool calls and skips the thread and turn events', () => {
it('renders tool activity and skips thread and turn events', () => {
expect(formatFixture('codex-tool-calls.jsonl')).toEqual([
{
level: 'info',
@@ -117,18 +118,17 @@ describe('CODEX_CLI.formatOutputLine', () => {
]);
});
it('skips what it does not know without failing', () => {
expect(format(JSON.stringify({ type: 'turn.completed', usage: {} }))).toEqual([]);
expect(
format(
JSON.stringify({
type: 'item.completed',
item: { id: 'item_1', type: 'reasoning', text: 'Thinking.' },
})
)
).toEqual([]);
expect(format(JSON.stringify({ type: 'item.completed', item: 'bad' }))).toEqual([]);
expect(format(JSON.stringify({ type: 'error', message: 42 }))).toEqual([]);
it('skips events without readable activity', () => {
for (const event of [
{ type: 'turn.completed', usage: {} },
{ type: 'item.completed', item: { id: 'item_1', type: 'reasoning', text: 'Thinking.' } },
{ type: 'item.completed', item: 'bad' },
{ type: 'error', message: 42 },
]) {
expect(format(JSON.stringify(event))).toEqual([]);
}
expect(format('null')).toEqual([]);
expect(format('[]')).toEqual([]);
expect(format('true')).toEqual([]);
});
@@ -0,0 +1,156 @@
import { CLAUDE_CODE_CLI } from '../claude';
import { CODEX_CLI } from '../codex';
import { readFixtureLines } from './readFixture';
describe.each([
['Codex', CODEX_CLI, 'codex-tool-calls.jsonl', ['item_0', 'item_3']],
['Claude Code', CLAUDE_CODE_CLI, 'claude-code-tool-call.jsonl', ['msg_002']],
] as const)('%s activity', (_name, cli, fixture, messageIds) => {
it('keeps tool arguments and pairs results with their calls', () => {
const events = readFixtureLines(fixture)
.flatMap(line => cli.formatOutputLine(line))
.flatMap(log => log.agentEvent?.content ?? []);
const calls = events.filter(event => event?.type === 'tool_call');
expect(calls.length).toBeGreaterThan(0);
expect(calls[0]).toMatchObject({ arguments: { command: 'echo hi' } });
for (const call of calls) {
expect(events).toContainEqual({
type: 'tool_result',
callId: call.callId,
text: call === calls[0] ? 'stub ran: echo hi' : 'no sandboxes',
isError: false,
});
}
expect(events.filter(event => event?.type === 'message').map(event => event.messageId)).toEqual(
messageIds
);
});
});
it('marks a Codex tool result as failed when the server returned an error in its content', () => {
expect(
CODEX_CLI.formatOutputLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'item_1',
type: 'mcp_tool_call',
server: 'expo',
tool: 'sandbox_list',
arguments: {},
status: 'failed',
error: null,
result: { content: [{ type: 'text', text: 'Network connection lost.' }] },
},
})
)
).toEqual([
{
level: 'warn',
message: 'Tool error: Network connection lost.',
agentEvent: {
producer: 'codex',
content: [
{
type: 'tool_result',
callId: 'item_1',
text: 'Network connection lost.',
isError: true,
},
],
},
},
]);
});
// A provider can report a recoverable error before it finishes the same invocation.
it.each([
[CODEX_CLI, { type: 'error', message: 'Retrying.' }, 'warning'],
[CODEX_CLI, { type: 'item.completed', item: { type: 'error', message: 'Retrying.' } }, 'warning'],
[CODEX_CLI, { type: 'turn.failed', error: { message: 'Stopped.' } }, 'error'],
[
CLAUDE_CODE_CLI,
{ type: 'system', subtype: 'init', mcp_servers: [{ name: 'expo', status: 'failed' }] },
'warning',
],
[
CLAUDE_CODE_CLI,
{ type: 'result', subtype: 'error', is_error: true, result: 'Stopped.' },
'error',
],
] as const)('keeps error severity in the structured event (%j, %j)', (cli, input, severity) => {
expect(cli.formatOutputLine(JSON.stringify(input))[0]?.agentEvent?.content[0]).toMatchObject({
type: 'error',
severity,
});
});
it('keeps all text blocks of a Claude message under the provider message ID', () => {
const events = CLAUDE_CODE_CLI.formatOutputLine(
JSON.stringify({
type: 'assistant',
message: {
id: 'msg_123',
content: [
{ type: 'text', text: 'First.' },
{ type: 'text', text: 'Second.' },
],
},
})
).flatMap(log => log.agentEvent?.content ?? []);
expect(events).toEqual([
{ type: 'message', messageId: 'msg_123', text: 'First.' },
{ type: 'message', messageId: 'msg_123', text: 'Second.' },
]);
});
// Source fields must not be copied into the shared event.
it.each([
['Claude Code', CLAUDE_CODE_CLI, 'claude-code-tool-call.jsonl', 3],
['Codex', CODEX_CLI, 'codex-tool-calls.jsonl', 6],
] as const)(
'keeps only typed activity without source data for %s',
(_name, cli, fixture, count) => {
const events = readFixtureLines(fixture).flatMap(line => {
const source = { ...JSON.parse(line), futureField: { image: 'base64-image-payload' } };
return cli
.formatOutputLine(JSON.stringify(source))
.flatMap(log => (log.agentEvent ? [log.agentEvent] : []));
});
expect(events).toHaveLength(count);
for (const event of events) {
expect(event.content.length).toBeGreaterThan(0);
expect(event).not.toHaveProperty('data');
}
expect(JSON.stringify(events)).not.toContain('base64-image-payload');
}
);
it('groups a Claude message with mixed content in one shared event', () => {
const source = {
type: 'assistant',
message: {
id: 'msg_123',
content: [
{ type: 'text', text: 'Checking.' },
{ type: 'tool_use', id: 'toolu_1', name: 'check', input: { path: '/' } },
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'base64-image-payload' },
},
],
},
};
const events = CLAUDE_CODE_CLI.formatOutputLine(JSON.stringify(source)).flatMap(log =>
log.agentEvent ? [log.agentEvent] : []
);
expect(events).toEqual([
{
producer: 'claude-code',
content: [
{ type: 'message', messageId: 'msg_123', text: 'Checking.' },
{ type: 'tool_call', callId: 'toolu_1', name: 'check', arguments: { path: '/' } },
],
},
]);
});
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { type AgentRunEvent, type AgentRunEventContent, SystemError } from '@expo/eas-build-job';
import { z } from 'zod';
import { type AgentRunProviderCredentials } from '../agentRunLease';
@@ -20,6 +20,38 @@ export interface AgentInvocation {
export interface AgentLogMessage {
level: 'info' | 'warn' | 'error';
message: string;
agentEvent?: Pick<AgentRunEvent, 'producer' | 'content'>;
}
export interface AgentContentLogMessage {
level: AgentLogMessage['level'];
message: string;
content?: AgentRunEventContent;
}
const AgentCliEventSchema = z.object({ type: z.string() }).passthrough();
/** Keep one structured record per CLI event alongside its readable log messages. */
export function formatAgentOutputLine(
line: string,
producer: AgentRunEvent['producer'],
formatEvent: (data: Record<string, unknown>) => AgentContentLogMessage[]
): AgentLogMessage[] {
const json = parseJsonLine(line);
if (json === undefined) {
return [{ level: 'info', message: line }];
}
const event = AgentCliEventSchema.safeParse(json);
if (!event.success) {
return [];
}
const messages = formatEvent(event.data);
const content = messages.flatMap(message => (message.content ? [message.content] : []));
const logs: AgentLogMessage[] = messages.map(({ level, message }) => ({ level, message }));
if (content.length) {
logs[0].agentEvent = { producer, content };
}
return logs;
}
export interface AgentCli {
@@ -4,6 +4,7 @@ import { z } from 'zod';
import {
type AgentCli,
type AgentContentLogMessage,
type AgentInvocation,
type AgentLogMessage,
ContentBlocksSchema,
@@ -12,7 +13,7 @@ import {
contentBlocksText,
createContinuationPrompt,
createProviderMismatchError,
parseJsonLine,
formatAgentOutputLine,
tolerantArray,
} from './agent';
import { type AgentRunProviderCredentials } from '../agentRunLease';
@@ -73,12 +74,14 @@ const AssistantBlockSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
z.object({
type: z.literal('tool_use'),
id: z.string().optional(),
name: z.string(),
input: z.record(z.string(), z.unknown()),
}),
]);
const ToolResultBlockSchema = z.object({
type: z.literal('tool_result'),
tool_use_id: z.string().optional(),
content: z.union([z.string(), ContentBlocksSchema]).default(''),
is_error: z.boolean().optional(),
});
@@ -90,7 +93,7 @@ const ClaudeCodeEventSchema = z.discriminatedUnion('type', [
}),
z.object({
type: z.literal('assistant'),
message: z.object({ content: tolerantArray(AssistantBlockSchema) }),
message: z.object({ id: z.string().optional(), content: tolerantArray(AssistantBlockSchema) }),
}),
z.object({
type: z.literal('user'),
@@ -106,10 +109,10 @@ const ClaudeCodeEventSchema = z.discriminatedUnion('type', [
]);
function formatClaudeCodeOutputLine(line: string): AgentLogMessage[] {
const json = parseJsonLine(line);
if (json === undefined) {
return [{ level: 'info', message: line }];
}
return formatAgentOutputLine(line, 'claude-code', formatClaudeCodeEvent);
}
function formatClaudeCodeEvent(json: Record<string, unknown>): AgentContentLogMessage[] {
const event = ClaudeCodeEventSchema.safeParse(json);
if (!event.success) {
return [];
@@ -122,36 +125,59 @@ function formatClaudeCodeOutputLine(line: string): AgentLogMessage[] {
: {
level: 'warn',
message: `MCP server ${server.name} is not connected (${server.status}).`,
content: {
type: 'error',
severity: 'warning',
message: `MCP server ${server.name} is not connected (${server.status}).`,
},
}
);
case 'assistant':
case 'assistant': {
const messageId = event.data.message.id;
return event.data.message.content.map(block => {
switch (block.type) {
case 'text':
return { level: 'info', message: `Agent: ${block.text}` };
return {
level: 'info',
message: `Agent: ${block.text}`,
content: { type: 'message', messageId, text: block.text },
};
case 'tool_use':
return {
level: 'info',
message: `Tool call: ${block.name} ${JSON.stringify(block.input)}`,
content: block.id
? { type: 'tool_call', callId: block.id, name: block.name, arguments: block.input }
: undefined,
};
}
});
}
case 'user':
return event.data.message.content.map(block => {
const text =
typeof block.content === 'string' ? block.content : contentBlocksText(block.content);
return block.is_error
? { level: 'warn', message: `Tool error: ${text}` }
: { level: 'info', message: `Tool result: ${text}` };
return {
level: block.is_error ? 'warn' : 'info',
message: `${block.is_error ? 'Tool error' : 'Tool result'}: ${text}`,
content: block.tool_use_id
? { type: 'tool_result', callId: block.tool_use_id, text, isError: !!block.is_error }
: undefined,
};
});
case 'result': {
// `result` repeats the agent's last text block, so on success there is nothing to add.
// A successful `result` repeats the last text block; it needs no additional readable message.
const { is_error, result, errors, subtype } = event.data;
return is_error
? [
{
level: 'error',
message: `Agent failed: ${result || errors?.join('\n') || subtype}`,
content: {
type: 'error',
severity: 'error',
message: result || errors?.join('\n') || subtype,
},
},
]
: [];
@@ -4,6 +4,7 @@ import { z } from 'zod';
import {
type AgentCli,
type AgentContentLogMessage,
type AgentInvocation,
type AgentLogMessage,
ContentBlocksSchema,
@@ -12,7 +13,7 @@ import {
contentBlocksText,
createContinuationPrompt,
createProviderMismatchError,
parseJsonLine,
formatAgentOutputLine,
} from './agent';
import { type AgentRunProviderCredentials } from '../agentRunLease';
@@ -112,9 +113,10 @@ export async function prepareCodexAsync({
}
const CodexItemSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('agent_message'), text: z.string() }),
z.object({ type: z.literal('agent_message'), id: z.string().optional(), text: z.string() }),
z.object({
type: z.literal('mcp_tool_call'),
id: z.string().optional(),
server: z.string(),
tool: z.string(),
arguments: z.record(z.string(), z.unknown()).nullable(),
@@ -135,10 +137,10 @@ const CodexEventSchema = z.discriminatedUnion('type', [
]);
function formatCodexOutputLine(line: string): AgentLogMessage[] {
const json = parseJsonLine(line);
if (json === undefined) {
return [{ level: 'info', message: line }];
}
return formatAgentOutputLine(line, 'codex', formatCodexEvent);
}
function formatCodexEvent(json: Record<string, unknown>): AgentContentLogMessage[] {
const event = CodexEventSchema.safeParse(json);
if (!event.success) {
return [];
@@ -153,32 +155,69 @@ function formatCodexOutputLine(line: string): AgentLogMessage[] {
{
level: 'info',
message: `Tool call: ${item.server}.${item.tool} ${JSON.stringify(item.arguments ?? {})}`,
content: item.id
? {
type: 'tool_call',
callId: item.id,
name: `${item.server}.${item.tool}`,
arguments: item.arguments ?? {},
}
: undefined,
},
];
}
case 'item.completed':
return formatCompletedItem(event.data.item);
case 'error':
return [{ level: 'warn', message: `Error: ${event.data.message}` }];
return [
{
level: 'warn',
message: `Error: ${event.data.message}`,
content: { type: 'error', severity: 'warning', message: event.data.message },
},
];
case 'turn.failed':
return [{ level: 'error', message: `Agent failed: ${event.data.error.message}` }];
return [
{
level: 'error',
message: `Agent failed: ${event.data.error.message}`,
content: { type: 'error', severity: 'error', message: event.data.error.message },
},
];
}
}
function formatCompletedItem(
item: z.output<typeof CodexItemSchema> | undefined
): AgentLogMessage[] {
): AgentContentLogMessage[] {
switch (item?.type) {
case 'agent_message':
return [{ level: 'info', message: `Agent: ${item.text}` }];
return [
{
level: 'info',
message: `Agent: ${item.text}`,
content: { type: 'message', messageId: item.id, text: item.text },
},
];
case 'mcp_tool_call': {
const resultText = contentBlocksText(item.result?.content ?? []);
return item.status === 'failed'
? [{ level: 'warn', message: `Tool error: ${item.error?.message ?? resultText}` }]
: [{ level: 'info', message: `Tool result: ${resultText}` }];
const text = item.error?.message ?? contentBlocksText(item.result?.content ?? []);
const isError = item.status === 'failed';
return [
{
level: isError ? 'warn' : 'info',
message: `${isError ? 'Tool error' : 'Tool result'}: ${text}`,
content: item.id ? { type: 'tool_result', callId: item.id, text, isError } : undefined,
},
];
}
case 'error':
return [{ level: 'warn', message: `Error: ${item.message}` }];
return [
{
level: 'warn',
message: `Error: ${item.message}`,
content: { type: 'error', severity: 'warning', message: item.message },
},
];
case undefined:
return [];
}
@@ -0,0 +1,42 @@
import { z } from 'zod';
export const AgentRunEventContentZ = z.discriminatedUnion('type', [
z.object({
type: z.literal('message'),
// The CLI message or item ID can be shared by several blocks or events.
messageId: z.string().optional(),
text: z.string(),
}),
z.object({
type: z.literal('tool_call'),
callId: z.string(),
name: z.string(),
arguments: z.record(z.string(), z.unknown()),
}),
z.object({
type: z.literal('tool_result'),
callId: z.string(),
text: z.string(),
isError: z.boolean(),
}),
z.object({
type: z.literal('error'),
severity: z.enum(['warning', 'error']),
message: z.string(),
}),
]);
/**
* Shared by the worker and event consumers. One record represents one CLI JSON event.
* Use eventId to deduplicate records. Match CLI message and call IDs within invocationId.
*/
export const AgentRunEventZ = z.object({
version: z.literal(1),
eventId: z.string(),
invocationId: z.string(),
producer: z.enum(['claude-code', 'codex']),
content: z.array(AgentRunEventContentZ),
});
export type AgentRunEventContent = z.infer<typeof AgentRunEventContentZ>;
export type AgentRunEvent = z.infer<typeof AgentRunEventZ>;
+1
View File
@@ -34,6 +34,7 @@ export * from './compositeFunction';
export * from './submission-config';
export * from './projectPackage';
export * from './deviceRunSession';
export * from './agentRunEvent';
export * from './sandbox';
const version = require('../package.json').version;
@@ -135,6 +135,87 @@ describe('logger', () => {
expect(logs[1].msg).toBe('another ****** in base64 is ********************');
});
it('masks structured agent content before both upload streams, including empty messages', async () => {
config.loggers.http.baseUrl = 'https://logs.expo.test/logs/';
config.buildId = 'build-id';
config.loggers.gcs.signedUploadUrlForLogs = { url: 'https://gcs.expo.test/logs', headers: {} };
const decoded = 'private"value\\with-slash';
const encoded = Buffer.from(decoded).toString('base64');
const { logger, cleanUp } = await createBuildLoggerWithSecretsFilter({
robotAccessToken: 'robot-token',
environmentSecrets: [
{ name: 'JOB_SECRET', value: 'job-secret', type: EnvironmentSecretType.STRING },
{ name: 'ENCODED_SECRET', value: encoded, type: EnvironmentSecretType.STRING },
],
});
const agentEvent = {
version: 1,
eventId: 'event-id',
invocationId: 'invocation-id',
producer: 'codex',
content: [
{ type: 'message', text: `Message: ${decoded}` },
{
type: 'tool_call',
callId: 'call-id',
name: 'tool',
arguments: {
nested: [{ 'job-secret': [encoded, decoded, true, 12, null] }],
},
},
{ type: 'tool_result', callId: 'call-id', text: 'Result: job-secret', isError: false },
],
};
const original = JSON.stringify(agentEvent);
logger.info({ agentEvent }, `Message: ${decoded}`);
logger.info({ agentEvent }, '');
await waitForStreamFlush();
await cleanUp();
expect(HttpLogStream.instances).toHaveLength(1);
expect(RemoteLoggerStream.instances).toHaveLength(1);
for (const stream of [HttpLogStream.instances[0], RemoteLoggerStream.instances[0]]) {
expect(stream.writes).toHaveLength(2);
expect(stream.writes.map(log => log.msg)).toEqual([
`Message: ${'*'.repeat(decoded.length)}`,
'',
]);
for (const log of stream.writes) {
expect(log.agentEvent).toEqual({
version: 1,
eventId: 'event-id',
invocationId: 'invocation-id',
producer: 'codex',
content: [
{ type: 'message', text: `Message: ${'*'.repeat(decoded.length)}` },
{
type: 'tool_call',
callId: 'call-id',
name: 'tool',
arguments: {
nested: [
{
'**********': [
'*'.repeat(encoded.length),
'*'.repeat(decoded.length),
true,
12,
null,
],
},
],
},
},
{ type: 'tool_result', callId: 'call-id', text: 'Result: **********', isError: false },
],
});
expect(JSON.stringify(log)).not.toContain('job-secret');
expect(JSON.stringify(log)).not.toContain(encoded);
}
}
expect(JSON.stringify(agentEvent)).toBe(original);
});
it('adds logId to each log', async () => {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({});
+28 -11
View File
@@ -63,6 +63,26 @@ function createTransformStream(secrets: EnvironmentSecret[]): Transform {
...secretValues.map(maybeStringBase64Decode).filter((i): i is string => !!i),
].filter(i => i.length > 1 && !simpleSecretsWhitelist.includes(i));
const maskText = (text: string): string =>
secretList.reduce(
(masked, pattern) => masked.replaceAll(pattern, '*'.repeat(pattern.length)),
text
);
const maskAgentEvent = (value: unknown): unknown => {
if (typeof value === 'string') {
return maskText(value);
}
if (Array.isArray(value)) {
return value.map(maskAgentEvent);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [maskText(key), maskAgentEvent(item)])
);
}
return value;
};
return new Transform({
readableObjectMode: true,
writableObjectMode: true,
@@ -78,18 +98,15 @@ function createTransformStream(secrets: EnvironmentSecret[]): Transform {
};
}
if (chunk && typeof chunk === 'object' && chunk.msg) {
const msgWithoutSecrets = secretList.reduce((acc: string, pattern: string): string => {
return acc.replaceAll(pattern, '*'.repeat(pattern.length));
}, chunk.msg as string);
callback(null, {
...chunk,
msg: msgWithoutSecrets,
});
} else {
callback(null, chunk);
if (chunk && typeof chunk === 'object') {
if (typeof chunk.msg === 'string') {
chunk.msg = maskText(chunk.msg);
}
if (chunk.agentEvent) {
chunk.agentEvent = maskAgentEvent(chunk.agentEvent);
}
}
callback(null, chunk);
},
});
}