diff --git a/crates/execution/assets/runners/python-runner.mjs b/crates/execution/assets/runners/python-runner.mjs index 2b86d9d071..a8dfe850e9 100644 --- a/crates/execution/assets/runners/python-runner.mjs +++ b/crates/execution/assets/runners/python-runner.mjs @@ -2345,17 +2345,20 @@ del _agentos_install_bundled_finder bundledFiles.has(lockfilePackages[normalizePythonPackageName(distributionName)]?.file_name); } +// Filename for runner-owned Python source, so tracebacks can drop its frames. +const PYTHON_INTERNAL_FILENAME = ''; + // `pip` / `python -m pip`: emulate the common pip CLI via Pyodide's micropip, // which fetches wheels through the runner's kernel-backed fetch (so egress is // governed by the VM network policy, never an ambient host fetch). Installed // packages are copied into the persistent VFS site-packages so they survive the // per-process interpreter and can be imported by a later `python` invocation. // Bundled packages are importable in every process, so they are not copied. -async function runPythonPip(pyodide, isBundledPackage) { +async function runPythonPip(pyodide, runMain, isBundledPackage) { pyodide.globals.set('__agentos_vfs_site', PYTHON_VFS_SITE_PACKAGES); pyodide.globals.set('__agentos_is_bundled_package', isBundledPackage); try { - await pyodide.runPythonAsync(` + return await runMain(` import importlib.metadata, os, shutil, site, sys _agentos_pip_args = sys.argv[1:] if _agentos_pip_args and _agentos_pip_args[0] == "install": @@ -2398,7 +2401,7 @@ elif _agentos_pip_args and _agentos_pip_args[0] == "list": else: print("usage: pip install [ ...]", file=sys.stderr) sys.exit(2) -`); +`, PYTHON_INTERNAL_FILENAME); } finally { try { pyodide.globals.delete('__agentos_vfs_site'); @@ -2414,8 +2417,8 @@ else: // Minimal interactive REPL backed by the kernel stdin stream (sys.stdin via // setStdin). Prompts use the standard PS1/PS2; EOF on stdin ends the session. -async function runPythonRepl(pyodide) { - await pyodide.runPythonAsync(` +function runPythonRepl(runMain) { + return runMain(` import sys from code import InteractiveConsole if not hasattr(sys, "ps1"): @@ -2423,7 +2426,50 @@ if not hasattr(sys, "ps1"): if not hasattr(sys, "ps2"): sys.ps2 = "... " InteractiveConsole(locals={"__name__": "__main__", "__doc__": None}).interact(banner="", exitmsg="") -`); +`, PYTHON_INTERNAL_FILENAME); +} + +// Returns `runMain(source, filename)`, which runs a main program in `__main__` +// and resolves to its process exit status the way CPython decides it: +// `SystemExit` maps to its code, and any other uncaught exception prints a +// Python traceback through `sys.excepthook` and exits 1. Without this, +// `SystemExit` escapes Pyodide's event loop as an uncaught JS error. +function installPythonMainRunner(pyodide) { + pyodide.runPython( + ` +async def _agentos_run_main(source, filename): + import __main__, sys + from _pyodide._base import __file__ as pyodide_base_file, eval_code_async + + try: + await eval_code_async(source, __main__.__dict__, filename=filename) + return 0 + except SystemExit as error: + code = error.code + if code is None: + return 0 + if isinstance(code, int): + return code & 0xFF + print(code, file=sys.stderr) + return 1 + except BaseException as error: + tb = error.__traceback__ + while tb is not None and tb.tb_frame.f_code.co_filename in ( + ${JSON.stringify(PYTHON_INTERNAL_FILENAME)}, + pyodide_base_file, + ): + tb = tb.tb_next + sys.excepthook(type(error), error, tb) + return 1 + finally: + sys.stdout.flush() + sys.stderr.flush() +`, + { filename: PYTHON_INTERNAL_FILENAME }, + ); + const runMain = pyodide.globals.get('_agentos_run_main'); + pyodide.globals.delete('_agentos_run_main'); + return runMain; } function resolvePythonSource(pyodide) { @@ -2512,9 +2558,6 @@ try { error, ); }); - if (retainLanguageContext) { - globalThis.__agentOSRetainedPyodide = pyodide; - } restorePyodideShellCompat(); emitWarmupStage('after-load-pyodide'); const loadPyodideMs = realPerformance.now() - loadPyodideStarted; @@ -2553,23 +2596,31 @@ try { loadPyodideMs, source, }); + const runMain = installPythonMainRunner(pyodide); + if (retainLanguageContext) { + globalThis.__agentOSRunRetainedPython = (code) => runMain(code, ''); + } if (moduleName === 'pip') { - await runPythonPip(pyodide, isBundledPackage); + process.exitCode = await runPythonPip(pyodide, runMain, isBundledPackage); } else if (moduleName) { pyodide.globals.set('__agentos_module', moduleName); try { - await pyodide.runPythonAsync( + process.exitCode = await runMain( 'import runpy\nrunpy.run_module(__agentos_module, run_name="__main__", alter_sys=True)', + PYTHON_INTERNAL_FILENAME, ); } finally { pyodide.globals.delete('__agentos_module'); } } else if (stdinProgram) { - await pyodide.runPythonAsync(readProgramFromStdin()); + process.exitCode = await runMain(readProgramFromStdin(), ''); } else if (interactive) { - await runPythonRepl(pyodide); + process.exitCode = await runPythonRepl(runMain); } else { - await pyodide.runPythonAsync(resolvePythonSource(pyodide)); + process.exitCode = await runMain( + resolvePythonSource(pyodide), + readRunnerEnv(PYTHON_FILE_ENV) ?? '', + ); } } } catch (error) { diff --git a/crates/execution/src/python.rs b/crates/execution/src/python.rs index 80fcc1f51b..af7e0b5ad1 100644 --- a/crates/execution/src/python.rs +++ b/crates/execution/src/python.rs @@ -544,7 +544,7 @@ impl PythonExecution { ))) })?; let runner = format!( - "process.exitCode = 0; (async () => {{ const pyodide = globalThis.__agentOSRetainedPyodide; if (!pyodide) throw new Error('retained Python interpreter is unavailable'); await pyodide.runPythonAsync({source}); }})()" + "process.exitCode = 0; (async () => {{ const runPython = globalThis.__agentOSRunRetainedPython; if (!runPython) throw new Error('retained Python interpreter is unavailable'); process.exitCode = await runPython({source}); }})()" ); self.inner .execute_retained(runner, String::from("/[agentos-python-retained]"), false) diff --git a/crates/native-sidecar/tests/python.rs b/crates/native-sidecar/tests/python.rs index ab3295304a..56eeb4aee8 100644 --- a/crates/native-sidecar/tests/python.rs +++ b/crates/native-sidecar/tests/python.rs @@ -3564,6 +3564,56 @@ fn python_command_runs_inline_code() { assert_eq!(stdout, "2\n", "stderr: {stderr}"); } +fn python_command_exits_with_cpython_status_and_traceback() { + assert_node_available(); + let mut sidecar = new_sidecar("python-cli-exit-status"); + let cwd = temp_dir("python-cli-exit-status-cwd"); + let connection_id = authenticate_wire(&mut sidecar, "conn-python"); + let session_id = open_session_wire(&mut sidecar, 2, &connection_id); + let (vm_id, _) = create_vm_wire( + &mut sidecar, + 3, + &connection_id, + &session_id, + GuestRuntimeKind::Python, + &cwd, + ); + + let cases = [ + ("raise SystemExit(7)", 7, ""), + ("import sys; sys.exit(0)", 0, ""), + ("import sys; sys.exit('msg')", 1, "msg\n"), + ( + "raise ValueError('boom')", + 1, + "Traceback (most recent call last):\n File \"\", line 1, in \nValueError: boom\n", + ), + ]; + for (index, (source, expected_exit, expected_stderr)) in cases.into_iter().enumerate() { + let process_id = format!("proc-py-exit-{index}"); + execute_python_cli( + &mut sidecar, + 4 + index as RequestId, + &connection_id, + &session_id, + &vm_id, + &process_id, + "python3", + &["-c", source], + ); + let (stdout, stderr, exit_code) = collect_process_output( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + &process_id, + ); + assert_eq!(exit_code, expected_exit, "{source}\nstderr: {stderr}"); + assert_eq!(stderr, expected_stderr, "{source}"); + assert!(stdout.is_empty(), "{source}\nstdout: {stdout}"); + } +} + fn python_command_runs_script_with_argv() { assert_node_available(); let mut sidecar = new_sidecar("python-cli-argv"); @@ -4104,6 +4154,7 @@ fn python_rootfs_suite() { fn python_cli_suite() { python_command_runs_inline_code(); + python_command_exits_with_cpython_status_and_traceback(); python_command_runs_script_with_argv(); python_command_runs_module_with_dash_m(); python_command_reads_program_from_stdin(); @@ -4212,6 +4263,7 @@ mod python_split { python_runtime_runs_node_subprocesses_through_sidecar_bridge, python_runtime_surfaces_subprocess_permission_errors, python_command_runs_inline_code, + python_command_exits_with_cpython_status_and_traceback, python_command_runs_script_with_argv, python_command_runs_module_with_dash_m, python_command_reads_program_from_stdin, diff --git a/docs/content/docs/python.mdx b/docs/content/docs/python.mdx index 362c56a1aa..8e11844f81 100644 --- a/docs/content/docs/python.mdx +++ b/docs/content/docs/python.mdx @@ -67,6 +67,11 @@ Executions are ephemeral, so capture stdio only when you want it: `"stderr"` for diagnostics, `"all"` for both streams. `onStdout`/`onStderr` stream live and work independently of capture. +The exit code follows CPython. `sys.exit()` and `SystemExit` set it: no +argument exits `0`, an integer exits with that code, and any other value is +printed to stderr and exits `1`. An uncaught exception prints a Python traceback +to stderr and exits `1`. + ## Pass data into code In the library, `inputs` hands host values to the guest as real objects, so data never gets