diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 1a4a26b81e..dcd3d053a3 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -410,11 +410,13 @@ function resolvePythonBin(bin: string): string { /* v8 ignore next -- PATH is set in every environment the runtime boots in; the guard is defensive. */ if (path === undefined) return bin for (const dir of path.split(delimiter)) { - // An empty PATH segment (a `::`, implicitly CWD on POSIX) is skipped so a - // basename never resolves against the working directory; normal PATHs - // carry no empty segment. - /* v8 ignore next -- normal PATHs carry no empty segment. */ - if (dir === '') continue + // An empty PATH segment (a `::`, implicitly CWD on POSIX) and a RELATIVE + // segment (`bin` or `.`) are skipped: a basename must never resolve against + // the working directory, and the returned candidate must be an absolute + // path — spawn() resolves a relative pythonBin against the host CWD, which + // is outside the seam contract. + /* v8 ignore next -- normal PATHs carry no empty or relative segment. */ + if (dir === '' || !isAbsolute(dir)) continue const candidate = join(dir, bin) try { accessSync(candidate, fsConstants.X_OK) 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 0accc86bfc..d179059ec2 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -4901,4 +4901,37 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(result.logs).toContain('after-cap-frames') }, 120_000) + it('rejects an oversized first frame that lands on the sealing threshold with a newline', async () => { + // The fragment-count seal runs only on newline-free chunks (the ELSE half + // of the newline branch), so a chunk that carries the first newline always + // reaches the join and its first-frame check. Fail-before: sealing that + // chunk into a block would empty pendingChunks, leave sawNewline false, + // skip the first-frame check, and join the oversized first frame whole. + // Whether the pipe delivers exactly 1024 chunks is timing-dependent, but + // the oversized first frame (63.9 MiB of A's + 12288 more before the + // newline) exceeds FRAME_PARSE_CAP_BYTES no matter how it arrives. + const { runtime } = await setup({ maxWallMs: 60_000, addressSpaceMb: 2048 }) + const result = await runtime.run({ + program: [ + 'import os', + // 4 KiB writes are <= PIPE_BUF, so each os.write is atomic and the + // host sees one chunk per write; 16384 of them accumulate 64 MiB of + // newline-free bytes (16 fragment-count seals of 1024 chunks). + 'chunk = b"A" * 4096', + 'for _ in range(16384):', + ' os.write(3, chunk)', + // 12289 more A's push the first frame past 64 MiB; drain-loop so the + // write cannot truncate, then a newline and a small legitimate frame. + "data = b'A' * 12289 + b'\\n' + b'{\"type\":\"log\",\"text\":\"after-seal\"}\\n'", + 'view = memoryview(data)', + 'while view:', + ' view = view[os.write(3, view):]', + 'return "done"', + ].join('\n'), + bindings: [], + }) + expect(result.error?.kind).toBe('worker-exit') + expect(result.error?.message).toContain('protocol frame exceeded') + }, 120_000) + })