Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion electron/ipc/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const DEFAULT_AGENTS: AgentDef[] = [
command: 'codex',
args: [],
resume_args: ['resume', '--last'],
skip_permissions_args: ['--full-auto'],
skip_permissions_args: ['--dangerously-bypass-approvals-and-sandbox'],
description: "OpenAI's Codex CLI agent",
},
{
Expand Down
178 changes: 124 additions & 54 deletions electron/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,66 @@ import path from 'path';
import type { BrowserWindow } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn } = vi.hoisted(() => {
const mockExecFileSync = vi.fn((command: string, args?: string[]) => {
if (command === 'which' && args?.[0] === 'nonexistent-binary-xyz') {
throw new Error('not found');
}
return '';
});

const mockExecFile = vi.fn();
const mockChildProcessSpawn = vi.fn(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
}));

const mockPtySpawn = vi.fn(
(_command: string, _args: string[], options: { cols: number; rows: number }) => {
let onDataHandler: ((data: string) => void) | undefined;
let onExitHandler:
| ((event: { exitCode: number; signal: number | undefined }) => void)
| undefined;

const proc = {
cols: options.cols,
rows: options.rows,
write: vi.fn(),
resize: vi.fn((cols: number, rows: number) => {
proc.cols = cols;
proc.rows = rows;
}),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(() => {
onExitHandler?.({ exitCode: 0, signal: 15 });
}),
onData: vi.fn((handler: (data: string) => void) => {
onDataHandler = handler;
}),
onExit: vi.fn(
(handler: (event: { exitCode: number; signal: number | undefined }) => void) => {
onExitHandler = handler;
const { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn, mockLogDebug } =
vi.hoisted(() => {
const mockExecFileSync = vi.fn((command: string, args?: string[]) => {
if (command === 'which' && args?.[0] === 'nonexistent-binary-xyz') {
throw new Error('not found');
}
return '';
});

const mockExecFile = vi.fn();
const mockChildProcessSpawn = vi.fn(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
}));

const mockPtySpawn = vi.fn(
(_command: string, _args: string[], options: { cols: number; rows: number }) => {
let onDataHandler: ((data: string) => void) | undefined;
let onExitHandler:
| ((event: { exitCode: number; signal: number | undefined }) => void)
| undefined;

const proc = {
cols: options.cols,
rows: options.rows,
write: vi.fn(),
resize: vi.fn((cols: number, rows: number) => {
proc.cols = cols;
proc.rows = rows;
}),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(() => {
onExitHandler?.({ exitCode: 0, signal: 15 });
}),
onData: vi.fn((handler: (data: string) => void) => {
onDataHandler = handler;
}),
onExit: vi.fn(
(handler: (event: { exitCode: number; signal: number | undefined }) => void) => {
onExitHandler = handler;
},
),
emitData(data: string) {
onDataHandler?.(data);
},
),
emitData(data: string) {
onDataHandler?.(data);
},
emitExit(event: { exitCode: number; signal: number | undefined }) {
onExitHandler?.(event);
},
};
emitExit(event: { exitCode: number; signal: number | undefined }) {
onExitHandler?.(event);
},
};

return proc;
},
);
return proc;
},
);

return { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn };
});
const mockLogDebug = vi.fn();

return { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn, mockLogDebug };
});

vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
Expand All @@ -76,6 +79,10 @@ vi.mock('node-pty', () => ({
spawn: mockPtySpawn,
}));

vi.mock('../log.js', () => ({
debug: mockLogDebug,
}));

import {
buildDockerImage,
DOCKER_CONTAINER_HOME,
Expand Down Expand Up @@ -155,6 +162,14 @@ function getFlagValues(args: string[], flag: string): string[] {
return values;
}

function getSpawnCommandLogCtx(): { args: string[]; command: string } {
const call = mockLogDebug.mock.calls.find(
([category, msg]) => category === 'pty' && String(msg).startsWith('spawn command '),
);
expect(call).toBeTruthy();
return call?.[2] as { args: string[]; command: string };
}

function makeTempHome(entries: string[]): string {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-docker-home-'));
tempPaths.push(home);
Expand Down Expand Up @@ -229,6 +244,61 @@ describe('spawnAgent docker mode', () => {
expect(envFlags).not.toContain(`HOME=${rendererHome}`);
});

it('redacts docker env values in spawn debug logs', () => {
spawnAgent(
createMockWindow(),
buildSpawnArgs({
env: {
API_KEY: 'secret-api-key',
NO_VALUE: '',
},
}),
);

const ctx = getSpawnCommandLogCtx();
const logged = ctx.args.join(' ');

expect(ctx.command).toBe('docker');
expect(getFlagValues(ctx.args, '-e')).toContain('API_KEY=<redacted>');
expect(getFlagValues(ctx.args, '-e')).toContain('NO_VALUE=<redacted>');
expect(getFlagValues(ctx.args, '-e')).toContain(`HOME=<redacted>`);
expect(logged).not.toContain('secret-api-key');
expect(logged).not.toContain(`HOME=${DOCKER_CONTAINER_HOME}`);
expect(logged).toContain('parallel-code-agent:test');
});

it('redacts inline docker env values in spawn debug logs', () => {
spawnAgent(
createMockWindow(),
buildSpawnArgs({
args: ['--env=INLINE_TOKEN=inline-secret', '--env', 'SPLIT_TOKEN=split-secret'],
}),
);

const logged = getSpawnCommandLogCtx().args.join(' ');

expect(logged).toContain('--env=INLINE_TOKEN=<redacted>');
expect(logged).toContain('SPLIT_TOKEN=<redacted>');
expect(logged).not.toContain('inline-secret');
expect(logged).not.toContain('split-secret');
});

it('redacts shell command strings in spawn debug logs', () => {
spawnAgent(
createMockWindow(),
buildSpawnArgs({
command: '/bin/sh',
args: ['-c', 'codex exec "prompt containing private context"'],
dockerMode: false,
}),
);

const ctx = getSpawnCommandLogCtx();

expect(ctx.command).toBe('/bin/sh');
expect(ctx.args).toEqual(['-c', '<redacted>']);
});

it('redirects credential mounts under /tmp inside the container', () => {
const home = makeTempHome(['.ssh/', '.gitconfig', '.config/gh/']);
vi.stubEnv('HOME', home);
Expand Down
52 changes: 52 additions & 0 deletions electron/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,50 @@ const BATCH_INTERVAL = 8; // ms
const TAIL_CAP = 8 * 1024;
const MAX_LINES = 50;

function redactedSpawnArgs(command: string, args: string[]): string[] {
if ((command === '/bin/sh' || command.endsWith('/sh')) && args[0] === '-c') {
return ['-c', '<redacted>'];
}
if (command === 'docker') {
return redactDockerArgs(args);
}
return args;
}

function redactDockerArgs(args: string[]): string[] {
const redacted: string[] = [];
let redactNextEnv = false;

for (const arg of args) {
if (redactNextEnv) {
redacted.push(redactEnvAssignment(arg));
redactNextEnv = false;
continue;
}

if (arg === '-e' || arg === '--env') {
redacted.push(arg);
redactNextEnv = true;
continue;
}

if (arg.startsWith('--env=')) {
redacted.push(`--env=${redactEnvAssignment(arg.slice('--env='.length))}`);
continue;
}

redacted.push(arg);
}

return redacted;
}

function redactEnvAssignment(value: string): string {
const eqIdx = value.indexOf('=');
if (eqIdx <= 0) return '<redacted>';
return `${value.slice(0, eqIdx)}=<redacted>`;
}

/** Verify that a command exists in PATH. Throws a descriptive error if not found. */
export function validateCommand(command: string): void {
if (!command || !command.trim()) {
Expand Down Expand Up @@ -230,6 +274,14 @@ export function spawnAgent(
spawnArgs = args.args;
}

logDebug('pty', `spawn command ${args.agentId}`, {
taskId: args.taskId,
command: spawnCommand,
args: redactedSpawnArgs(spawnCommand, spawnArgs),
cwd,
dockerMode: args.dockerMode === true,
});

const proc = pty.spawn(spawnCommand, spawnArgs, {
name: 'xterm-256color',
cols: args.cols,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"test:coverage": "vitest run --coverage",
"check": "npm run typecheck && npm run lint && npm run format:check",
"release": "npm run typecheck && npm version patch && git push --follow-tags",
"postinstall": "node scripts/fix-node-pty-spawn-helper.mjs",
"prepare": "husky && git config core.hooksPath .husky"
},
"license": "MIT",
Expand Down
18 changes: 18 additions & 0 deletions scripts/fix-node-pty-spawn-helper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { chmodSync, existsSync } from 'fs';
import { join } from 'path';
import process from 'process';

if (process.platform === 'darwin') {
const helperPath = join(
process.cwd(),
'node_modules',
'node-pty',
'prebuilds',
`darwin-${process.arch}`,
'spawn-helper',
);

if (existsSync(helperPath)) {
chmodSync(helperPath, 0o755);
}
}
2 changes: 1 addition & 1 deletion src/arena/ConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { BattleCompetitor } from './types';
/** Built-in tool presets — click to fill the next empty competitor slot */
const TOOL_PRESETS: Array<{ name: string; command: string }> = [
{ name: 'Claude', command: 'claude -p "{prompt}" --dangerously-skip-permissions' },
{ name: 'Codex', command: 'codex exec --full-auto "{prompt}"' },
{ name: 'Codex', command: 'codex exec --dangerously-bypass-approvals-and-sandbox "{prompt}"' },
{ name: 'Gemini', command: 'gemini -p "{prompt}" --yolo' },
{ name: 'Copilot', command: 'copilot -p "{prompt}" --yolo' },
{ name: 'Aider', command: 'aider -m "{prompt}" --yes' },
Expand Down
Loading