Skip to content

review: guest-boundary fixes, SDK fixes, cuts, control-plane keep-alive - #278

Merged
CMGS merged 30 commits into
mainfrom
review/whole-repo-1001
Oct 1, 2026
Merged

CMGS merged 30 commits into
mainfrom
review/whole-repo-1001

Conversation

@CMGS

@CMGS CMGS commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Whole-repo pass over every Go, Rust and Python file (296 files), plus the follow-ups from it. Every fix has a test that fails before it; performance changes carry interleaved A/B numbers.

Guest-boundary fixes (sandboxd, silkd)

  • Port frames are capped at MaxFrame. guestPortConn read with ReadBytes, so a guest root that replaces silkd could stream one never-ending frame and grow sandboxd until the host OOM-kills it. The new ReadSlice path also drops a ~350 KB allocation and copy per relayed frame.
  • The volume device probe bounds what the guest returns (4096 entries, 64 KiB). A hostile agent in a promoted template could otherwise feed MountVolume's 2 s probe without limit.
  • silkd runs git as the repository's owner. git 2.35.2+ refuses repos root does not own ("dubious ownership"), so every git verb but clone failed on a repo created by an exec user or extracted by fs_push. We drop to the owner instead of setting safe.directory=*, so repo config never runs as root.
  • Upload temp files are created with O_EXCL under their final mode, and the mode is fixed through the handle. A guest user could plant symlinks at the predictable temp names and have root silkd write through them; the file also sat at 0644 during a 0600 upload.
  • fs_push holds its staging and destination by fd. The staging dir could be swapped for a symlink before tar's chdir, and the merge walked both trees by path. tar now starts in the staging fd, and the merge uses openat/renameat/unlinkat with O_NOFOLLOW. A root run on overlayfs caught a stale listing through the pre-extract fd, so the merge lists through a fresh openat(".").
  • A push tar that rejects mid-stream reports tar's error, not EPIPE.

Other correctness fixes

  • A failed peer pull stops reading the record instead of draining a multi-GB body before trying the next owner.
  • A guest env change on a failed claim answers 409 instead of storing the env and returning 500.
  • Record locks are dropped with their last reference. Before, every claimed or fetched checkpoint/template id kept a mutex for the life of the process.
  • Minting a preview URL rejects port 0, matching the ports relay.
  • SDKs (Go, Python): an upload that a pre-proto-2 daemon rejects returns the daemon's error frame instead of broken pipe.
  • sdk/openai: persist_workspace strips the root basename, so a restored workspace is no longer empty.
  • sdk/langchain: a failed release never hides the error raised in the with block.
  • sdk/python: proxy_port binds a bracketed IPv6 address.

Performance

  • silkd: port_forward and lsp uploads decode data frames on the zero-copy path. 256 KiB frame: 138.7-141.5 → 96.6-97.4 µs (−31%); 32 KiB: −33%.
  • sandboxd Promote releases the source sandbox's transition lock before publishing, so exec, relay and wake on the source no longer wait for the digest or the upload.
  • sdk/python reuses a kept control-plane connection per node: up to 8 idle connections, reused within keep_alive and only after a liveness probe, never retried. Claim p50 against a Go server on loopback: 403/353 → 81/73 µs.
  • The s3 fetch cache drops superseded generations once no fetch has used them for store.GenerationGrace. A cache hit refreshes the generation's use time.

Cuts (behavior-preserving)

  • jsontext SkipValue replaces the hand-rolled tag-scan skipper.
  • commitIfLive folds three persist-if-live tails.
  • One CA parser handles root and intermediate.
  • s3 publishes in one walk of export/.
  • noContent replaces seven identical 204 closures.
  • mcp drops the read_file stat precheck (silkd checks the opened fd) and the late-claim release that raced process exit.

Review commits

Layout (type blocks own their constructors and producers), Go 1.27 idioms (strings.CutLast, new(expr), min/max), and a comment-budget pass. Plus docs: Info/node_info need the operator token, PoolSpec lists every config-owned field, and a config pool's egress policy stays bound to its key after PUT /v1/pools drops the pool.

Numbers

Production code: Go −119, Go scaffold −8, Python +46, Rust +80 (net −1). Tests +379. Comment lines in Go/Rust: +26/−60.

Verification

  • make go-lint: 10 × 0 issues. (5 modules × linux/darwin).
  • asl -forwarder=false: 0 findings on both GOOS.
  • make go-test: 19 ok.
  • Go race tests on linux/arm64 as a non-root user: 17 ok.
  • sdk/go GOOS=windows go vet: clean.
  • Rust: make lint and make test on macOS (159 passed); on linux/arm64 as root, boot/init 17 and silkd 144 passed, fmt/clippy clean.
  • Python 3.12: ruff clean, mypy strict clean, pytest 224 + 10 + 15 passed.

Not changed here: 209 in-body Go comments that start lowercase (the casing rule is deferred).

CMGS added 29 commits October 1, 2026 13:32
…te methods follow the public ones

newJournalWriter joins journalWriter, Client.dialAgent trails upgradedConn (/code producer rule), and silkd's Table/session pub(crate) helpers move below the pub methods (/code-rs public-above-private).
…strings to one line; __init__ returns annotated
guestPortConn.Read used bufio.ReadBytes, which buffers an unterminated line without bound. A guest root that replaces silkd on the agent port can stream one never-ending port_forward data frame and grow sandboxd until the host OOM-kills it, taking every tenant's control plane with it. Every other guest-frame reader is capped at wire.MaxFrame.

readFrame takes the line with ReadSlice: a canonical data frame fits portReadBuf and is decoded straight out of the reader's buffer, so the per-frame allocation and copy ReadBytes made is gone too. Only a frame larger than the buffer is assembled, and that stops at MaxFrame.
silkdList and silkdReadFile appended every frame the guest agent sent. A tenant can promote a template whose agent is hostile; a claim of it with a volume then makes MountVolume's 2 s device probe collect as much as the guest streams. Both helpers only read /sys/block and a sysfs serial file, so cap them at 4096 entries and 64 KiB (the largest sysfs page).
HTTPPuller drained the response body on every return. After Untar failed partway (ENOSPC, a corrupt or truncated tar) the drain kept downloading the rest of a multi-GB record into io.Discard until the per-owner budget ran out, and only then did the healer try the next owner. One pull per heal gains nothing from connection reuse, so close the body without draining.
…rame

A silkd before proto 2 answers a failing fs_write or fs_push with an error frame and closes. uploadRPC kept sending Data frames until one hit EPIPE and returned that, dropping the typed reason already read into terminal. A failed send now waits for the terminal read, which the closed connection ends at once, and prefers its error. The lease is not marked reusable on that path.
…r frame

Same defect as the Go SDK: a silkd before proto 2 answers a failing fs_write or fs_push with an error frame and closes, the relay closes the client once its guest write fails, and the next chunk raised ProtocolError(broken pipe) with the typed reason unread. write_file and push now share _upload, which reads the terminal frame after a failed send and raises it, else the send error. FakeAgent gains slam, the relay's close on a failed guest write.
…y config-owned field

GET /v1/info is root-only, so an MCP server started with a tenant token was refused on every node_info call with nothing in the tool description or docs saying why. PoolSpec's godoc named egress only, while PUT /v1/pools also rejects warmup, capture_trim and storage.
writeEnv refused a guest change on a paused claim but not a failed one, so after the VMM exited a PUT or PATCH stored the env, wrote to a dead vsock and answered 500; the docs tell a caller to resend after a 500, and every resend failed the same way. Every sibling verb already refuses a failed claim. Host-only changes stay accepted.
silkd runs as root, and git 2.35.2+ refuses a repository whose owner is someone else ("detected dubious ownership"), so every git verb but clone failed on a repo a workload created as an exec user or that fs_push extracted with the archive's uids. git_cmd now drops to the owner of the directory when silkd is root and root does not own it, with that user's HOME and USER when the uid has a passwd entry. This keeps git's guard against repo-controlled config running as root, which safe.directory=* would have dropped, and objects git writes stay owned by the repo's owner.

Verified as root on linux/arm64 (rust:1.98.1, git 2.47.3): the new test fails on main with the dubious-ownership error and passes with the fix, for an owner with a passwd entry (65534) and without one (501).
…nal mode

fs_write and write_atomic (fs_replace) opened a predictable <path>.silkd-<pid>-<seq>.tmp with O_CREAT|O_TRUNC and chmod'ed it by path at commit. A non-root guest process that can write the destination directory could plant symlinks at the next names, and root silkd then wrote the upload through the link and chmod'ed its target. The temp file also sat at 0644 for the whole upload when 0600 was asked.

create_tmp opens with create_new (O_EXCL, never follows a link) under the resolved mode and fixes the exact bits through the handle (fchmod), so a planted name fails the write with EEXIST and the file never exists wider than asked. Syscall count is unchanged on the upload path. Verified as root on linux/arm64 and on macOS.
…e broken pipe

When tar refused an archive and exited before the stream ended (GNU tar on a compressed stream without -z), the next write to its stdin failed with EPIPE and push_staged answered "write: Broken pipe". A failed stdin write with a failed tar now reports tar's stderr; a bad or truncated client stream still wins. The test fails on linux/arm64 GNU tar 1.35 before the fix; macOS bsdtar reads the whole stream, so it passes there either way.
…ero-copy path

feed_client parsed every client frame through serde, while fs_write and fs_push already slice a canonical data frame's base64 straight out of the line. It now tries decode_data_frame first and moves the decoded Vec into the handler's channel, falling back to serde for any other shape.

Interleaved A/B in one release run, order swapped each round (macOS arm64): 256 KiB frame 138.7-141.5 us -> 96.6-97.4 us (-31%), 32 KiB 18.2-18.6 us -> 12.3-12.5 us (-33%).
…kspace root

silkd's fs_pull carries a tree under its own basename, so persist_workspace returned workspace/... members while the Agents SDK's snapshots are relative to the root; hydrating one pushed the files to /workspace/workspace/... and the agent saw an empty workspace. Strip the root's name with the SDK's own strip_tar_member_prefix (present since openai-agents 0.17.0, the declared floor), as its docker backend does. The test's pull now returns a silkd-shaped tar instead of opaque bytes, which had hidden the defect.
…ck's error

CocoonToolkit.__exit__ let a release APIError replace the exception the block raised, so a failing agent run surfaced as "release: ... (HTTP 500)". It now mirrors Sandbox.__exit__: a release failure raises only after a clean block.
proxy_port split local_addr at the last colon and handed "[::1]" to create_server, which cannot resolve it, so an IPv6 listen address always failed while the Go SDK's ProxyPort takes it. Strip the brackets and bind AF_INET6 for an IPv6 host.
The ports relay answers 400 for port 0, but POST /preview minted a URL for it whose every request then failed 502. Refuse it at mint time with the relay's message.
…publishing

Promote held the sandbox's transition lock through PublishDigested: a full-content sha256 of the export on the dir store, the upload on s3. Every exec, relay, port or wake on the source sandbox waited the whole time. The lock only pins the source snapshot during the export, so Promote now exports through exportSource, as Checkpoint does, and publishes after the lock is released; publishTemplate folds away (prod -13).
recDone kept a record's lock entry at zero references unless a delete had asked for eviction, so every checkpoint or template id this node claimed or fetched but never deleted itself left a mutex in recLocks for the life of the process; on s3 or a cluster, deletes happen on the owner, so that is every claimed checkpoint. A reference is taken before the lock is waited on, so at zero there is no holder and no waiter and a fresh mutex is equivalent: drop the entry there. recDoneEvict, evictIfPending and recEvict fold away (prod -26); the eviction-ordering tests hold as written.
…walk, noContent

Five behavior-preserving folds from the 2026-10-01 loc-justify round (CMGS approved the cut-list):
- wire scanTag skips siblings with jsontext's iterative SkipValue (depth cap 10000) instead of a hand-rolled walker; the depth error text changes (-23)
- failLocked, restartLocked's tail and commitTransition share commitIfLive; resolvePendingSnap keeps its own lock-held read of the pending name (-19)
- LoadCA and IssueIntermediate parse root and intermediate with one bundle parser, so a trailing non-CA block is rejected in every file (-15)
- s3 publishFiles walks export/ once for both publish modes; object keys are unchanged and checkpoint publishes now reject a non-regular file like digested ones (-13)
- seven 204 closures become noContent(w) (-10)
…past the grace

A template id is a hash of its key, so every re-promote of the same name is a new generation of one record, and each node that fetched one kept every generation's full export under staging/cache/<id>/ until restart or Delete. A cache hit now refreshes the generation's mtime, and installing a new generation removes siblings no fetch used within store.GenerationGrace, the bound the dir backend already gives a clone of a superseded generation (moved from dir to store so both share it). Hot-path cost: one utimes per cache hit.
Every claim, release, renew, owner or peers call opened a new connection through urllib, and a TLS handshake over https. Client now parks up to 8 idle http.client connections per origin and reuses one when it is younger than keep_alive and a zero-timeout select shows the node has not closed it; keep_alive=0 keeps dialing per call. A failed request is never retried, so a claim cannot be sent twice; a 4xx/5xx whose body breaks off keeps its status, as before.

Interleaved A/B against a Go HTTP server on loopback, 2000 claims per arm, order swapped: p50 403.2/352.6 us (old) -> 81.0/73.1 us (new).
A guest user that can write a push destination could swap silkd's root-owned staging directory for a symlink between its creation and tar's chdir, so root tar extracted wherever the link pointed; the merge then walked staging and destination by path, so a swapped path made root rename the link target's files into the destination, and a destination subdirectory swapped between the check and the recursion redirected the merge.

stage_dir now creates staging with mkdirat (0700), reopens it O_NOFOLLOW and requires silkd's uid, which no guest user can forge. tar starts in that fd through fchdir in pre_exec, and merge_tree walks openat/renameat/unlinkat with O_NOFOLLOW at every level. The destination path itself is still taken as the client gave it.

The new test swaps the staging path for a symlink to a victim directory between stage_dir and push_staged; the same sequence on main moves the victim's file into the destination. Running it as root on linux/arm64 with /tmp on overlayfs caught a stale listing through the pre-extract fd, so merge_tree lists through a fresh openat of ".". nix gains the dir and fs features (no new crates).
read_file stat'ed the path before reading it, one extra relay RPC per call: silkd's fs_read has checked the kind on the opened fd since the same release, so the precheck only caught what silkd refuses; guests before v0.1.12 lose it. trackBox released a claim that landed after closeBoxes, but main returns once closeBoxes does, so that release raced process exit; such a claim is left to its tool lease, which docs/mcp.md now says (prod -17).
… /v1/pools drops the pool

effectivePolicy reads the pool layer from the config-owned policy map by key, so a config pool with an egress block that PUT /v1/pools drops keeps applying its policy to later claims of that key; only a key without a config-file policy takes its layer from live pool membership at claim time. The page implied the dropped pool's key had no pool layer. Never more permissive than the config.
@CMGS CMGS changed the title Whole-repo review round 2026-10-01: guest-boundary fixes, SDK fixes, cuts, control-plane keep-alive review: guest-boundary fixes, SDK fixes, cuts, control-plane keep-alive Oct 1, 2026
@CMGS
CMGS merged commit 90230c0 into main Oct 1, 2026
3 checks passed
@CMGS
CMGS deleted the review/whole-repo-1001 branch October 1, 2026 09:16
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