Skip to content

fix(python): name the program in tracebacks and run it in its own namespace - #425

Open
mutewinter wants to merge 3 commits into
vercel-labs:mainfrom
mutewinter:fix/python3-tracebacks-name-the-script
Open

mutewinter wants to merge 3 commits into
vercel-labs:mainfrom
mutewinter:fix/python3-tracebacks-name-the-script

Conversation

@mutewinter

@mutewinter mutewinter commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Problem

await bash.exec(`python3 -c "import json
x = 1
raise KeyError(2)"`);
// Traceback (most recent call last):
//   File "/tmp/_jb_script.py", line 413, in <module>
//     raise KeyError(2)
// KeyError: 2

A three-line program fails at line 413 of a file nobody wrote. A script file gets the same treatment: python3 report.py reports /tmp/_jb_script.py too, so the path and line number are wrong in every traceback the runtime produces, which is the one output an agent debugging its script reads most carefully. Related: sys.exit("message") exits 1 silently where CPython prints the message, __file__ is unset for a script, sys.path[0] is the wrapper's /tmp, and the wrapper's own imports leak into the program:

await bash.exec(`python3 -c "print(sorted(k for k in globals() if not k.startswith('__')))"`);
// ['Path', '_JbHttp', '_JbHttpResponse', '_base64', '_glob_module', ... 'json', 'os', 'sys', 'types']

The same paste also rewrites the program's data. Every line goes into the wrapper's try: block with four spaces in front, including the lines inside a triple-quoted string, so a multi-line literal silently gains content, and a program that builds code in a string and runs it with exec() fails with an IndentationError it did not write. #431 reports that half independently, with the same diagnosis; this PR fixes it by the same means and pins it with the tests below.

await bash.exec(`python3 - <<'PY'\ntext = """alpha\nbeta\n"""\nprint(repr(text))\nPY`);
// 'alpha\n    beta\n    '     <- CPython prints 'alpha\nbeta\n'

Cause

runPython pastes input.pythonCode into the wrapper's try: block after roughly four hundred lines of path shims and the HTTP bridge, and runs the whole file:

${input.pythonCode
  .split("\n")
  .map((line) => `    ${line}`)
  .join("\n")}
except SystemExit as e:

So the program is part of /tmp/_jb_script.py, its line numbers are offset by the wrapper's length (which varies with the environment variable count), and it shares the wrapper's module globals. traceback.print_exc() then prints the wrapper's own frame first. The existing tests assert on stdout and on ValueError appearing in stderr, never on the file or line a traceback names.

Fix

The program is compiled with compile(code, name, 'exec') under its own name (<string> for -c and -m, <stdin> for a program read from stdin, the script path as typed for a file) and run with exec in a fresh __main__ namespace. python3.ts passes the name as a new fileName on WorkerInput. For a script file, __file__ is the absolute path and sys.path[0] is /host plus the script's directory, so a sibling module imports without the /host prefix the current test comment documents; for inline code, sys.path[0] is '' as CPython sets it. The except clause prints from e.__traceback__.tb_next, which drops the wrapper's frame, and sys.exit with a non-integer code prints it to stderr.

JSON.stringify produces the Python literal: JSON's escapes are a subset of Python's, and the wrapper is written to MEMFS as UTF-8.

Scope

Unchanged: the path shims, the HTTP bridge and jb_http, sys.argv, exit codes, and runpy behavior for -m, whose tracebacks already named real files because runpy compiles them itself.

Not addressed, deliberately: frames inside the shims themselves (_redir_open and friends) still appear below the program's frame when an OSError comes through one, and BaseExceptions other than Exception still propagate through the wrapper. Both are small; say the word and either is a few lines.

Tests

python3.tracebacks.test.ts, a new file registered in the fork pool beside the other real-worker suites: -c code fails at File "<string>", line 3; a script fails at File "report.py", line 4 and line 2, in fail with no wrapper frame; a syntax error prints as CPython prints it; __file__ is /tmp/app/main.py and import helper finds a sibling without touching sys.path; the program is sys.modules['__main__'], so a class it defines pickles; a coding cookie is accepted; the program's non-dunder globals are []; a triple-quoted string keeps its lines unindented from a script, from stdin, and from -c; code the program builds in a string and runs with exec() runs; and a module-level from __future__ import is honored. python3.env.test.ts: sys.exit('error message') writes error message\n to stderr, and other non-integer codes print and exit 1. Ten of the twelve traceback cases fail against the wrapper as it is on main, verified by running the file against a worker built from main's worker.ts in a separate worktree, not inferred. The existing runpy and /host/tmp sys.path tests still pass unchanged.

Suite: the moved and new cases plus python3.files.test.ts, 28 passed.


Authored with Claude Opus 5

…mespace

The program was pasted into the worker's wrapper script, so every traceback named /tmp/_jb_script.py at a line four hundred past the program's own, for -c code and script files alike, and the wrapper's helpers sat in the program's globals. The program is compiled under its own name (<string>, <stdin>, or the script path as typed) and run in a fresh __main__ namespace, the wrapper's frame is dropped from what is printed, __file__ and sys.path[0] are set for a script file as CPython sets them, and sys.exit("message") prints the message before exiting 1.
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

@mutewinter is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@auto-maintain

auto-maintain Bot commented Sep 11, 2026

Copy link
Copy Markdown

🤖 auto-maintain review

Automated, advisory triage for @mutewinter's PR. Facts below are read from the GitHub API.

Check Result
Author's merged PRs (this repo) 10
Account established ✅ (age 5942d · 190 followers · 130 public repos)
Commits signed/verified ✅ 3/3
Changeset included ✅ (.changeset/python3-tracebacks-name-the-script.md)

Review panel: 🟡 medium highest severity

just-bash maintainer code review: 🟡 medium

The traceback fix is mostly sound, but stdin execution still constructs an incompatible `main` namespace.

  • packages/just-bash/src/commands/python3/worker.ts:1449 — Stdin programs never receive `file`: both `python3 -` and piped input will raise `NameError`, whereas CPython sets it to `'<stdin>'`. Set `file` for the stdin main module too.

General code review: 🟡 medium

The traceback fix introduces a module-shadowing regression in its exception handler.

  • packages/just-bash/src/commands/python3/worker.ts:1451 — Prepending the program directory to sys.path makes the later deferred `import traceback` resolve a sibling `traceback.py`. Any script directory containing that common filename can replace or break exception formatting, masking the original error. Import and retain the stdlib module before modifying sys.path.

Adversarial security: 🟡 medium

The traceback fix introduces an unchecked source-size amplification in the Python worker.

  • packages/just-bash/src/commands/python3/worker.ts:1455 — Serializing attacker-controlled Python source with JSON.stringify can expand it up to 6× after the worker-message size check. A permitted 64 MiB request can therefore allocate hundreds of MiB across the escaped source, wrapper, and encoded script, enabling memory-exhaustion DoS. Enforce a limit on the generated script or avoid escaping the source into the wrapper.

Adversarial security (second opinion): 🟡 medium

No backdoors, exfiltration, or dependency/CI tampering; the JSON.stringify-into-Python-literal construction is injection-safe. The new sys.path handling is the weak spot: it front-loads program-controlled directories before the wrapper's lazy `import traceback`, and leaks the `/host` mount prefix into imported modules' paths. Fix those before merge.

  • packages/just-bash/src/commands/python3/worker.ts:1484 — The wrapper's `import traceback` is deferred into the `except` handler, which now runs after `sys.path.insert(0, '')` / `'/host'+scriptdir` has put attacker-writable directories at the front of `sys.path`. A `traceback.py` sitting in the working directory (e.g. an untrusted repo checkout) is imported and executed the first time any Python program raises — including `python3 -c "1/0"`, which imports nothing itself — giving repo-controlled code execution with the VFS and HTTP bridge. Real CPython prints tracebacks from C and is not exposed this way. Move `import traceback` to the top of the wrapper, before the program's `sys.path` entry is inserted.
  • packages/just-bash/src/commands/python3/worker.ts:1451 — `sys.path.insert(0, '/host' + dirname(file))` makes sibling modules resolve under the internal mount prefix, so `helper.file` becomes `/host/tmp/app/helper.py` and any traceback frame inside a sibling prints `File "/host/tmp/app/helper.py"` — a path that does not exist from the shell's view (`cat /host/...` fails). The main script gets a clean path but imported modules do not, which reintroduces the wrong-path traceback problem this PR sets out to fix and leaks the sandbox mount layout. The new test only asserts `helper.ANSWER`, so it does not catch this.
  • packages/just-bash/src/commands/python3/worker.ts:1450 — `os.path.abspath` here goes through the `_redir_getcwd` shim, which returns `cwd[5:]` — the empty string when the guest cwd is exactly `/host` (i.e. shell cwd `/`). For a relative script path in that case `file` stays relative (`'foo.py'`, where CPython gives an absolute path) and `os.path.dirname` yields `''`, so the previous line inserts bare `/host` — the entire VFS root — onto `sys.path`. Compute the script's absolute path from the known cwd rather than relying on the stripping shim.

Standard Bash and host portability: 🟡 medium

Relative script filenames can still produce incorrect traceback source after a directory change.

  • packages/just-bash/src/commands/python3/worker.ts:1442 — Compiling file scripts with the path as typed leaves relative `co_filename` values. If the script changes directories before failing, traceback line lookup can omit the source or display a different same-named file; standard CPython uses an absolute script filename. Compile with the resolved logical path instead.

Posted by auto-maintain. This automated code review is advisory; a human maintainer makes the call.

A fresh dict was the program's namespace, so sys.modules['__main__'] stayed the wrapper: pickle could not find a class the program defined, and unittest.main() and doctest.testmod() found nothing. The program now runs in a types.ModuleType('__main__') registered in sys.modules. A compile-time error, which has no program frame, prints from the exception alone the way CPython prints a SyntaxError, rather than naming the wrapper; every non-None, non-integer sys.exit value prints and exits 1, including '' and 0.0; and the worker is told where the program came from (source: file, stdin, inline) rather than inferring it from the file name, so a script called <stdin> is still a file.
@mutewinter

Copy link
Copy Markdown
Contributor Author

All four of the maintainer/general findings were right and are fixed in the second commit, with a test for each: the program now runs in a types.ModuleType('__main__') registered in sys.modules, so import __main__ is the program and pickle.dumps of a class it defines round-trips (unittest.main() and doctest look it up the same way); a compile-time error has no program frame and now prints from the exception alone, which is CPython's own SyntaxError shape, File "<string>", line 1 and no wrapper; sys.exit('') and sys.exit(0.0) print the value and exit 1; and the worker is told where the program came from (source: "file" | "stdin" | "inline") instead of reading it off the file name, so a script named <stdin> is still a file.

The PEP 263 finding does not hold on the vendored 3.13: compile() of a str whose first line is # -*- coding: utf-8 -*- (or latin-1) compiles without error; I checked both in this build. Compiling .encode('utf-8') instead would be worse for this pipeline, since the filesystem has already decoded the file as UTF-8 and a latin-1 cookie would then re-decode every non-ASCII literal. The source stays a str, and a test with a latin-1 cookie over UTF-8 text asserts the literal survives. Suite: 246 passed, 2 skipped across the 16 files; the three new tests for the findings fail against the first commit.

…ource reaching the compiler as written

The traceback and namespace cases move from python3.files.test.ts into python3.tracebacks.test.ts, registered in the fork pool like the other real-worker suites, so the two python3 PRs open at once no longer append to the same file.

Five cases pin a consequence of compiling the program from its own string literal rather than pasting it into the indented wrapper: a triple-quoted string keeps its lines unindented from a script, stdin, and -c; code the program builds in a string and runs with exec() no longer gains an IndentationError; and a module-level `from __future__` import is honored. All five fail on the wrapper as it was.
@mutewinter

Copy link
Copy Markdown
Contributor Author

Pushed 5bf12fe, two things.

The traceback cases now live in python3.tracebacks.test.ts, registered in the fork pool like the other real-worker suites, rather than appended to python3.files.test.ts. #424 appends to that same file, so whichever of the two merged second was going to conflict on it; now neither touches it.

While looking at #431, which reports the wrapper's four-space indentation rewriting the contents of triple-quoted strings, I checked whether this change already covers it: it does, since the program reaches compile() as its own string literal and never passes through the indented paste. Five cases pin that (a triple-quoted string from a script, stdin, and -c; code built in a string and run with exec(); a module-level from __future__ import), and all five fail against a worker built from main's worker.ts. The body now names #431 and the finding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant