Skip to content

Commit 308bada

Browse files
committed
feat: add sandbox agents with clearer PTY Python validation (#1221)
1 parent 0d61c3b commit 308bada

2 files changed

Lines changed: 144 additions & 2 deletions

File tree

packages/agents-core/src/sandbox/sandboxes/shared/pty.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
1+
import {
2+
spawn,
3+
spawnSync,
4+
type ChildProcessWithoutNullStreams,
5+
} from 'node:child_process';
6+
import { SandboxConfigurationError } from '../../errors';
27

38
const PTY_BRIDGE_UID_ENV = '__OPENAI_AGENTS_PTY_UID';
49
const PTY_BRIDGE_GID_ENV = '__OPENAI_AGENTS_PTY_GID';
10+
const PTY_BRIDGE_PYTHON_CHECK_SCRIPT = String.raw`
11+
import pty
12+
import select
13+
import signal
14+
import sys
15+
16+
sys.exit(0 if sys.version_info[0] >= 3 else 1)
17+
`;
518
const PTY_BRIDGE_SCRIPT = String.raw`
619
import errno
720
import os
@@ -95,6 +108,8 @@ except ChildProcessError:
95108
sys.exit(exit_status)
96109
`;
97110

111+
const checkedPtyBridgePythonExecutables = new Set<string>();
112+
98113
type PseudoTerminalSpawnOptions = {
99114
cwd?: string;
100115
env?: NodeJS.ProcessEnv;
@@ -108,6 +123,12 @@ export function spawnInPseudoTerminal(
108123
options: PseudoTerminalSpawnOptions = {},
109124
): ChildProcessWithoutNullStreams {
110125
const env = { ...(options.env ?? process.env) };
126+
const pythonExecutable = process.env.OPENAI_AGENTS_PYTHON ?? 'python3';
127+
assertPtyBridgePythonAvailable(pythonExecutable, {
128+
cwd: options.cwd,
129+
env,
130+
});
131+
111132
if (typeof options.uid === 'number') {
112133
env[PTY_BRIDGE_UID_ENV] = String(options.uid);
113134
}
@@ -116,7 +137,7 @@ export function spawnInPseudoTerminal(
116137
}
117138

118139
return spawn(
119-
process.env.OPENAI_AGENTS_PYTHON ?? 'python3',
140+
pythonExecutable,
120141
['-c', PTY_BRIDGE_SCRIPT, executable, ...args],
121142
{
122143
cwd: options.cwd,
@@ -125,3 +146,42 @@ export function spawnInPseudoTerminal(
125146
},
126147
);
127148
}
149+
150+
function assertPtyBridgePythonAvailable(
151+
pythonExecutable: string,
152+
options: {
153+
cwd?: string;
154+
env: NodeJS.ProcessEnv;
155+
},
156+
): void {
157+
const cacheKey = `${pythonExecutable}\0${options.cwd ?? ''}\0${options.env.PATH ?? ''}`;
158+
if (checkedPtyBridgePythonExecutables.has(cacheKey)) {
159+
return;
160+
}
161+
162+
const result = spawnSync(
163+
pythonExecutable,
164+
['-c', PTY_BRIDGE_PYTHON_CHECK_SCRIPT],
165+
{
166+
cwd: options.cwd,
167+
env: options.env,
168+
stdio: 'pipe',
169+
},
170+
);
171+
if (result.error || result.status !== 0) {
172+
throw new SandboxConfigurationError(
173+
'PTY support requires Python 3. Install python3 or set OPENAI_AGENTS_PYTHON to a Python 3 executable.',
174+
{
175+
pythonExecutable,
176+
cwd: options.cwd,
177+
path: options.env.PATH,
178+
status: result.status,
179+
signal: result.signal,
180+
error: result.error?.message,
181+
stderr: result.stderr?.toString('utf8').trim() ?? '',
182+
},
183+
);
184+
}
185+
186+
checkedPtyBridgePythonExecutables.add(cacheKey);
187+
}

packages/agents-core/test/sandboxes/unixLocal.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,83 @@ describe('UnixLocalSandboxClient', () => {
10431043
expect(output).not.toContain('tty no');
10441044
});
10451045

1046+
it('fails tty commands clearly when the Python PTY bridge is unavailable', async () => {
1047+
const originalPython = process.env.OPENAI_AGENTS_PYTHON;
1048+
const missingPython = join(rootDir, 'missing-python3');
1049+
process.env.OPENAI_AGENTS_PYTHON = missingPython;
1050+
1051+
try {
1052+
const client = new UnixLocalSandboxClient({
1053+
workspaceBaseDir: rootDir,
1054+
});
1055+
const session = await client.create(new Manifest());
1056+
1057+
await expect(
1058+
session.execCommand({
1059+
cmd: 'printf "hello\\n"',
1060+
shell: '/bin/sh',
1061+
login: false,
1062+
tty: true,
1063+
yieldTimeMs: 1_000,
1064+
}),
1065+
).rejects.toMatchObject({
1066+
code: 'configuration_error',
1067+
message:
1068+
'PTY support requires Python 3. Install python3 or set OPENAI_AGENTS_PYTHON to a Python 3 executable.',
1069+
details: expect.objectContaining({
1070+
pythonExecutable: missingPython,
1071+
}),
1072+
});
1073+
} finally {
1074+
if (originalPython === undefined) {
1075+
delete process.env.OPENAI_AGENTS_PYTHON;
1076+
} else {
1077+
process.env.OPENAI_AGENTS_PYTHON = originalPython;
1078+
}
1079+
}
1080+
});
1081+
1082+
it('checks relative PTY Python executables from the command cwd', async () => {
1083+
const originalPython = process.env.OPENAI_AGENTS_PYTHON;
1084+
const pythonPath = await whichPython();
1085+
1086+
try {
1087+
const client = new UnixLocalSandboxClient({
1088+
workspaceBaseDir: rootDir,
1089+
});
1090+
const session = await client.create(new Manifest());
1091+
await symlink(
1092+
pythonPath,
1093+
join(session.state.workspaceRootPath, 'python3'),
1094+
);
1095+
process.env.OPENAI_AGENTS_PYTHON = './python3';
1096+
1097+
const output = await session.execCommand({
1098+
cmd: 'test -t 0 && printf "tty yes\\n" || printf "tty no\\n"',
1099+
shell: '/bin/sh',
1100+
login: false,
1101+
tty: true,
1102+
yieldTimeMs: 1_000,
1103+
});
1104+
const finalOutput = await collectActiveCommandOutput(
1105+
{
1106+
writeStdin: (args) => session.writeStdin(args),
1107+
},
1108+
output,
1109+
);
1110+
1111+
expect(finalOutput).toContain('Process exited with code 0');
1112+
expect(finalOutput).toContain('tty yes');
1113+
expect(finalOutput).not.toContain('tty no');
1114+
} finally {
1115+
if (originalPython === undefined) {
1116+
delete process.env.OPENAI_AGENTS_PYTHON;
1117+
} else {
1118+
process.env.OPENAI_AGENTS_PYTHON = originalPython;
1119+
}
1120+
}
1121+
});
1122+
10461123
it('accepts absolute sandbox paths when the manifest root is slash', async () => {
10471124
const client = new UnixLocalSandboxClient({
10481125
workspaceBaseDir: rootDir,
@@ -2160,6 +2237,11 @@ async function writeUntilExit(
21602237
return combinedOutput;
21612238
}
21622239

2240+
async function whichPython(): Promise<string> {
2241+
const { stdout } = await execFileAsync('which', ['python3']);
2242+
return stdout.trim();
2243+
}
2244+
21632245
async function collectActiveCommandOutput(
21642246
session: {
21652247
writeStdin(args: {

0 commit comments

Comments
 (0)