Route browser fs and logs endpoints directly to the VM - #164
Conversation
Add fs and logs to the default direct-to-VM subresource prefixes so filesystem operations and log streaming use the cached browser base_url and JWT instead of the control plane. Serialize multipart array entries with indexed names so a file part stays associated with the sibling fields of its array entry, which repeated `files[][file]` names cannot express. Only retry a stale direct-to-VM auth failure on the control plane when the request body can be rebuilt byte for byte; a streamed body is consumed by the direct attempt, so retrying would send a truncated body. The stale route is evicted either way.
Scope the indexed multipart array names to fs.upload instead of changing the client's generic array encoding: the endpoint now flattens its own body with indexed names and asks extract_files for matching file part names, so load_extensions and any other multipart array keep their existing wire format. Prove a multipart file field can be rewound before treating a stale-JWT failure as retryable. A wrapper can report seekable() while seek() raises, which rendered the fallback body as an empty part. Evict a stale direct-to-VM route from the terminal error path too. Retry eligibility is only consulted when retries remain, so with max_retries=0 a VM 401/403 previously left the dead route cached and wedged later calls. Route only logs/stream rather than the whole logs subresource.
httpx reads the body of a non-streamed response inside send(), so a 401 or 403 whose body read fails surfaces as a connection error and never reaches the status-error path. The route eviction ran after that read, which left a dead JWT cached and wedged every later call for the session. Move eviction into an httpx response event hook, which runs once the status is known and before any body is read, for both the sync and async clients. `_should_retry` now only decides whether replaying the body on the control plane is safe.
A response hook registered by the caller runs in registration order, so one that reads a failing 401/403 body or raises would skip the eviction hook appended after it and leave the stale route cached. Prepend the hook in both the sync and async installers; registration stays once per route cache.
d1e21ed to
41ebeda
Compare
rgarcia
left a comment
There was a problem hiding this comment.
reviewed alongside the go (#174) and node (#178) siblings, including a control-plane vs kernel-images surface comparison. all 16 fs/* and logs/stream endpoints match on params, body fields, 2xx codes and content types, and metro-api's /browser/kernel/* handler already wakes standby VMs and records session activity for direct requests, so the routing change itself looks safe.
the response-hook placement is sound: it fires inside send() before the body read, so the 401-with-failing-body case evicts, and it runs before _should_retry so the rebuilt request misses the cache. the replayable check matches httpx behavior (FileField.render_data seeks to 0 on each render, so probing seek(0) is the right test), and write_file with a file object correctly lands in the non-replayable branch. the body-read-failure and hook-ordering tests are the ones that justify the design.
Questions
src/kernel/resources/browsers/fs/fs.py:514— this is a generated file; is thearray_format="indices"/indexed_multipart_bodychange captured as stainless custom code so it survives the next regen?src/kernel/resources/browsers/fs/fs.py:514— main emitsfiles[][dest_path], which the kernel-images upload parser rejects (empty index failsAtoi) and the control plane forwards untouched. wasfs.uploadalready failing via the control plane? if so this is a user-visible bug fix worth calling out in release notes
Nits
src/kernel/lib/browser_routing/routing.py:204— docstring should mention the hook is inserted into a caller-suppliedhttp_client'sevent_hookstests/test_browser_routing.py— control-plane mocks return204forfs/upload/fs/write_file; production returns201
Sayan-
left a comment
There was a problem hiding this comment.
Approving. Verified against a mock control plane and VM over real HTTP: fs/* and logs/stream route to the VM with ?jwt= and no Authorization, telemetry/events and replays stay on the control plane with bearer auth, indexed multipart names arrive as files[0][dest_path] / files[0][file], and the stale-JWT path replays a buffered body byte for byte while a streamed body surfaces the original AuthenticationError with the route evicted.
-
p2: with
max_retries=0there is no transparent fallback even for a buffered body. The direct 401 surfaces asAuthenticationErrorand recovery depends on the eviction, so the caller's next call reaches the control plane. Node and Go fall back inline regardless of retry config. Measured buffered, iterator and file-object bodies atmax_retries=0and2. -
p2: the old
files[][dest_path]encoding was rejected outright rather than mis-paired. The in-VM handler parses the index withstrconv.Atoi, which fails on an empty index and returns400 invalid form field, sofs.uploadwas failing for every call including single-file ones. Worth reflecting in the release note. -
p2, pre-existing and out of scope: the new
load_extensionsassertion pinsextensions[][name], which the control-plane rewriter regex does not match and the in-VM parser rejects.
|
@rgarcia @Sayan- addressed the review notes across all three sibling PRs in one pass:
On the generated Python I left the cross-SDK Validation is green: Python 705 passed, Node 422 passed plus build/lint, Go |
Sayan-
left a comment
There was a problem hiding this comment.
- rechecked the post-approval commit, current diff, green CI, and clean merge with current main; no new blocking findings.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bb70883. Configure here.
Sayan-
left a comment
There was a problem hiding this comment.
- p1
src/kernel/_base_client.py:1015-1025,1599-1609reproduced with default retries, an unseekablefs.write_filebody, and a direct 401 whose response body raisedReadTimeout: the direct route receivedpayload, the control plane received an empty body, and the call returned normally. - p1
src/kernel/lib/browser_routing/routing.py:278-292reproduced with a caller-supplied httpx request hook that calledrequest.read(): replayability changed from false to true, the direct route receivedpayload, fallback received an empty body, and the call returned normally. A custom auth handler requiring the request body produced the same result.
|
@Sayan- fixed in |

Summary
Adds
fsandlogs/streamto the defaultKERNEL_BROWSER_ROUTING_SUBRESOURCESprefixes, so every/browsers/{id}/fs/*operation (JSON, binary read/write, multipart upload, watch SSE) and/browsers/{id}/logs/streamgoes straight to the browser VM through the existing route cache (base_url+ JWT) instead of the control plane.Browser lifecycle/metadata, extensions, replays,
telemetry/events, and anything else underlogs/stay on the control plane.KERNEL_BROWSER_ROUTING_SUBRESOURCESstill overrides the list, and an empty value still disables routing entirely.Two Python-only fixes were needed before fs could route directly:
Indexed multipart array names, scoped to
fs.upload. Python previously serialized entries as repeatedfiles[][dest_path]/files[][file]parts. The filesystem parser rejects the empty index, sofs.uploadalready failed through the control plane as well as against the VM.fs.uploadnow flattens its own body with indexed names and asksextract_filesfor matching file-part names, producingfiles[0][dest_path]/files[0][file]. This fixes the existing upload failure in addition to enabling direct routing. The client's generic multipart array encoding is untouched, soload_extensions,deployments.create,extensions.create,fs.upload_zipand any future multipart array keep their existing wire format.Body replay safety on a stale-JWT fallback. A stale direct-to-VM 401/403 is retried by rebuilding the request from the original options, so it is only safe when the body can be serialized again byte for byte. The client now:
max_retries=0left a dead route cached — and because httpx reads a non-streamed body insidesend(), a 401/403 whose body read fails surfaces as a connection error that never reaches the status-error path at all. The hook is prepended, so a caller-supplied response hook that reads a failing body or raises cannot pre-empt it, and it is registered once per route cache, so a copied client does not stack hooks;seekable()is not treated as proof: a wrapper can reportTrueand still raise fromseek(), which would have rendered the fallback body as an empty part, so the rewind httpx would perform is attempted (and the position restored) before classifying the body as replayable;_prepare_request, before caller request hooks or custom httpx auth can buffer the built request while consuming the original body used for retries;Streamed bodies (a file object or iterator passed to
fs.write_file) are never replayed after stale VM auth. They surface the auth error when its response body is readable andAPIConnectionErrorwhen reading that error body fails; the caller's next call uses the control plane. Sync and async clients both changed.Tests
tests/test_browser_routing.py:fsandlogs/stream, and still excludetelemetry/events,fsx/...,logs,logs/history,logstream,extensions,replays(asserted both at the matcher and throughrewrite_direct_vm_options)list_files,move), binary read, binarywrite_file, indexed multipartupload,fs/watch/{id}/eventsSSE andlogs/streamSSE all route tobase_urlwith?jwt=, no API-keyAuthorization, and preserved query stringslogs/streamcancellation reaches the transportseekable()each make exactly one VM call, no control-plane fallback, surface the original auth error, and still evict the route (sync + async)max_retries=0401/403 for buffered and streamed bodies: original error raised, cache empty, next call goes to the control plane with bearer auth and nojwtfs.write_filebody plus a direct 401 (sync) / 403 (async) whose response body raisesReadErrororReadTimeoutsends the complete body once, does not retry an empty write on the control plane, surfacesAPIConnectionErrororAPITimeoutError, evicts the route, and leaves the next explicit call on the control planemax_retries=0, direct 401/403 responses whose body stream raises still surfaceAPIConnectionError, evict the route, and leave the next call on the control planejwtdirect_vm_request_body_is_replayableclassification for empty, buffered, streamed, seekable-multipart, lying-seekable(), no-seekable()-with-failing-seek(), and closed-file bodiesload_extensionsstill sendsextensions[][name]/extensions[][zip_file], and the generic multipart array path still sendsarray[], pinning that the indexed encoding is scoped tofs.uploadindexed_multipart_bodyskips omitted valuesEach new stale-JWT/eviction test was confirmed to fail against the corresponding pre-fix implementation.
Ran locally: full
pytest(709 passed, 3257 skipped), targeted pydantic v1 routing tests (60 passed), and targeted Ruff check/format plus mypy for the changed source files.Live validation
Ran against staging with real headless browsers:
fs.write_file,fs.read_file(binary),fs.list_files,fs.upload(bytes andBytesIOentries),fs.upload_zip,fs.download_dir_zip,fs.create_directory,fs.file_info,fs.set_file_permissions,fs.delete_directory,fs.watch.start/stopandlogs.streamall hithttps://<browser-host>/browser/kernel/...?jwt=...with no API-key header and returned the expected data. Uploaded files read back with the correct per-entry contents, which is what the indexed multipart names fix.telemetry/eventsand the browser delete stayed on the control plane. Re-verified after narrowing the multipart change and switching tologs/stream.Note
Medium Risk
Changes default request routing for fs/logs and central retry/eviction logic for direct VM auth failures, where incorrect replay could truncate uploads or leave sessions stuck on bad routes.
Overview
Direct-to-VM routing now includes
fsandlogs/streamin the defaultKERNEL_BROWSER_ROUTING_SUBRESOURCESlist, so browser filesystem calls and live log SSE go to the cached VM URL with JWT query auth instead of the control plane. Extensions, replays,telemetry/events, and otherlogs/*paths stay on the API origin; env overrides are unchanged.fs.uploadmultipart is fixed for the VM (and the prior control-plane failure) by flattening the body to indexed field names (files[0][dest_path]/files[0][file]) via newindexed_multipart_body; other multipart endpoints keep genericarray[]encoding.Stale VM JWT handling is reworked: httpx response hooks evict dead routes as soon as 401/403 is known (before body reads that can fail and wedge the cache).
prepare_direct_vm_requestrecords whether the body can be replayed; retries to the control plane (status-based or connection/timeout) only happen when replay is safe—streamed writes, non-rewindable upload files, and known-unreplayable bodies are not blindly retried. Sync/asyncKernelclients override_should_retry_on_connection_errorfor this behavior.Reviewed by Cursor Bugbot for commit 92acf13. Bugbot is set up for automated code reviews on this repo. Configure here.