diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index ed6c5b1c6a..df0a4bb42b 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -744,6 +744,15 @@ async def _run(channel: ProtocolChannel) -> None: # with no done frame, misreporting the run as a `worker-exit`. A frame local # is not reachable by `__main__._X = ...`, so the catch is immune. _BaseException = BaseException + # `RuntimeError` and the `_BindingRejection` marker class are likewise bound + # into locals: `dispatch`'s `call_failure` and its `except` clause resolve + # them at call time, and the program (running as `__main__`) can rebind the + # module globals — `__main__._BindingRejection = ValueError` would leak the + # marker type into model code, violating the class's conversion contract. + # The names differ from the module globals (`_RuntimeError_cls`) so the + # assignment RHS resolves the module global, not an unbound local. + _RuntimeError_cls = RuntimeError + _BindingRejection_cls = _BindingRejection # 1. Boot handshake. boot = channel.read_frame() if boot is None or boot.get("type") != "boot": @@ -861,7 +870,7 @@ async def _run(channel: ProtocolChannel) -> None: # the pre-errorClass behavior for namespaces that declared none. if error_class is not None: return error_class(name, message) - return RuntimeError(message) + return _RuntimeError_cls(message) # Validate the argument shape before claiming an id, so a rejected call # leaves no gap in the sequence the host checks. json.dumps would coerce @@ -907,7 +916,7 @@ async def _run(channel: ProtocolChannel) -> None: next_id += 1 try: return await fut - except _BindingRejection as exc: + except _BindingRejection_cls as exc: raise call_failure(str(exc)) from None namespaces: dict[str, Any] = {} diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index 526fb29081..692ed9d51b 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -674,6 +674,35 @@ describe('PythonCodeRuntime — programs and bindings', () => { expect(calls).toEqual([{ n: 1 }]) }) + it('keeps the rejection contract when _BindingRejection is rebound', async () => { + // `dispatch`'s except clause resolves `_BindingRejection` at call time; a + // program that rebinds `__main__._BindingRejection = ValueError` would + // otherwise let the internal marker type leak into model code (the program + // would catch a `ValueError` for a host rejection). The class is now bound + // into `_run` locals before the program runs, so a host rejection still + // surfaces as the declared `RuntimeError`. + const { runtime } = await setup() + const result = await runtime.run({ + program: [ + 'import __main__', + '__main__._BindingRejection = ValueError', + 'caught = ""', + 'try:', + ' await tools.fail({})', + 'except RuntimeError as e:', + ' caught = str(e)', + 'except Exception as e:', + ' caught = "WRONG TYPE: " + type(e).__name__', + 'return caught', + ].join('\n'), + bindings: tools({ + fail: async () => { throw new Error('nope') }, + }), + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe('nope') + }, 15_000) + it('still answers the call when the rejection value cannot be converted to a string', async () => { // `messageOf` calls `String(error)`, which runs the value's own conversion, // and this call site is a DETACHED async reply callback. A rejection whose @@ -4317,6 +4346,12 @@ describe('PythonCodeRuntime — hostile peer', () => { const result = await runtime.run({ program: [ 'import sys', + // `-u` makes the streams write-through; re-enable block buffering so + // the bytes sit in the wrapper until the SETTLEMENT drain flushes them + // — the drain path, not the -u immediate write, is what this case pins. + 'if hasattr(sys.__stdout__, "reconfigure"):', + ' sys.__stdout__.reconfigure(write_through=False)', + ' sys.__stderr__.reconfigure(write_through=False)', 'sys.__stdout__.write("orig stdout\\n")', 'sys.__stderr__.write("orig stderr\\n")', 'return "done"',