mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba0b3eb3c | ||
|
|
4adbe69b59 | ||
|
|
a79498c033 | ||
|
|
531f3f913b | ||
|
|
6e0f9f5174 | ||
|
|
3556e98f52 | ||
|
|
b92115584d |
No files matched your search
@@ -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>;
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user