Skip to content

fix: keep a published Unix socket's identity through startup failure - #112

Merged
roodboi merged 5 commits into
nextfrom
claude/hack-1211-socket-startup-ownership
Oct 1, 2026
Merged

roodboi merged 5 commits into
nextfrom
claude/hack-1211-socket-startup-ownership

Conversation

@roodboi

@roodboi roodboi commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR closes startup ownership gaps in Unix-socket publication that reviews of hack-dance/hack#109 found (HACK-1211). It is based on next at a7d09599. hack-dance/hack#108 (the Bun 1.4.2 pin) is held as a draft until this lands.

This summary describes the current head, 83571a61. The sections below record each review round in order; the first round's chmod-based mode handling was replaced in the second round.

1. The native HTTPS owner keeps its published identity through startup (src/backends/native-https-owner-server.ts)

Before, the owner discarded the identity listenPublishedUnixSocket returned. It recorded socketIdentity only after lstat, chmod, lstat of control.sock.

  • A failure right after publication skipped listener and endpoint cleanup (failOwner needs socketIdentity). The public link and the listener stayed alive.
  • A replacement in that window was chmoded and compared only to itself, so a foreign endpoint could be recorded.

Now:

  • The returned identity is kept at once. The first observation must match it: the same socket, this user's, mode 0600.
  • No path is ever chmoded: the socket is created with mode 0600.
  • Failure cleanup always closes the listener; the runtime's close-time unlink reaches only the retired staging name. It then removes the endpoint only while it is this listener's inode. A replacement is kept and the failure retained as evidence.
  • afterPublish is a documented optional test seam between publication and first observation; production passes none. The owner challenge (src/backends/native-project-https.ts) has the same seam as afterOwnerSocketPublish.

2. The publication helper (src/lib/unix-socket-publish.ts)

  • Partial start: listen() is inside the cleanup, so a runtime that binds the staging name and then fails leaves no listener. The helper removes only this attempt's socket, by recorded identity. An entry it cannot prove is its own (an ambiguous partial bind) is not removed by the helper.
  • Mode at creation: the staging socket is created with exactly its mode (the umask during Bun's synchronous bind), and its identity is recorded in the same tick. The MCP backend, the HTTPS owner and the owner challenge no longer chmod anything.
  • Publication and retirement: publication links only while the staging name is still this socket, never replacing an existing endpoint (EEXIST). The staging name is retired only while it is still this socket.
  • Failure close: an unproven staging entry is moved to a free holding name while the server closes, then put back by no-clobber link. Holding names are tried by no-clobber link over 8 fresh names, never replacing a holding entry.

Scope: the staging-name guarantees are best-effort within a directory private to this user. They cover accidental and concurrent entries, not an adversarial process of the same user. Residuals:

  • Each check and its operation run back to back, but by path.
  • An entry that cannot be moved aside (all 8 holding names occupied, or not hard-linkable) can be removed by the runtime's close.
  • After a successful return, the runtime's normal close unlinks the retired staging name and can remove an entry placed there afterwards.

Public endpoint contract unchanged: same paths, socket, mode 0600, recorded device/inode; published without replacing an existing file, removed only by identity, never chmoded.

Verification at 83571a61

  • 12 helper controls plus the owner, challenge and MCP controls pass on Bun 1.3.9 and 1.4.2. They cover:
    • bind-then-fail;
    • the AF_UNIX limit;
    • ambiguous partial bind;
    • replacement after bind and after publication;
    • an occupied holding name;
    • an owner failure or replacement right after publication.
  • 18 socket-related test files: 144/144 on 1.3.9 and on 1.4.2.
  • Full TypeScript suite on 1.3.9: 1,774 tests, 1,707 pass, 0 fail.
  • Mutation checks, per round, are listed below.
  • Privacy ok; no new host crash reports.

Refs HACK-1211.

Review history

First round (67891ff6), superseded in part

The first round kept a helper-side chmod of the staging inode (injectable setMode). The second round (5d99981d) replaced it with creation-time mode. The first round's controls are carried forward:

  • bind-then-fail;
  • the AF_UNIX limit, verified with no truncation on 1.4.2;
  • the owner startup window: foreign file kept exactly, fresh owner acquire and release.

Its mutation checks were: the identity recorded after the first observation, a public chmod, paths checked before the listener closes, and no cleanup after bind-then-fail.

CI at 67891ff6 and fix (ea0e0d07)

  • Failure: at 67891ff6, seven checks and Graphite passed. test failed 1 of 1,771 tests, with no Bun crash: tests/mcp-publication-readiness.test.ts, "socket is private before chmod without changing later file permissions".
  • Cause: its fixture tests/fixtures/mcp/private-bind.ts hooked chmod on the public mcp.sock to prove the socket is private before its mode is set and that the creation mask is restored. This PR moves that chmod onto the staging socket before publication, so the hook never fired.
  • Fix, same property:
    • the fixture observed the staging chmod with the same privacy and mask checks (since the second round it observes creation mode through lstat and refuses any chmod);
    • it requires mode 0600;
    • it fails if the published endpoint is ever chmoded by path.
  • Fixture mutation check: three mutations each fail it with their specific error: public-path chmod ("chmodded by path"), no private umask at bind ("public before chmod"), and the umask never restored ("mask leaked").
  • Verification:
    • all 18 socket-related test files: 141 pass, 0 fail (58 live-gated skips) on Bun 1.3.9 and on 1.4.2;
    • the full TypeScript suite locally on 1.3.9: 1,771 tests, 1,704 pass, 0 fail;
    • no new host crash reports.

CI at ea0e0d07: Runtime state models, and the stand-in fix (70c78f77)

  • Failure: at ea0e0d07, test passed. Runtime state models failed in the frontend acceptance controls (added by test: accept prepared-base startup through ordinary native hack up #99, merged meanwhile, which PR CI runs through the merge with next): test_stale_source_after_the_edit_is_refused got "up-second exited 0 before readiness".
  • Cause: a lost-update race in that test's stand-in runtime. Concurrent invocations (a foreground up while the driver polls ps) each did an unlocked read-modify-write of state.json. A ps that loaded the state before the second up saved its foreground token then saved its stale copy over it; lost call records failed the positive acceptance test the same way. It reproduced in 26 of 40 local runs.
  • Fix (test: serialize the frontend acceptance stand-in's shared state): each stand-in invocation holds an exclusive flock across its read-modify-write and releases it only before its long waits (the foreground loop and the ps-hang fault). 0 of 40 local runs fail. No assertion changed.
  • This branch was rebased onto the current next (a7d09599, including test: accept prepared-base startup through ordinary native hack up #99) so that the fixed file is part of this PR.

Second review: staging-path ownership (5d99981d)

At 70c78f77 all eight checks passed. A second review found three windows in src/lib/unix-socket-publish.ts where an entry this attempt could not prove was its own could be changed or removed:

  1. After an ambiguous partial bind (no recorded identity), cleanup unlinked any same-uid socket at the staging name.
  2. The mode was set by path after an awaited lstat, so a replacement in that gap could be chmoded before the postcheck refused it.
  3. On success the staging name was unlinked unconditionally after the awaited link and endpoint lstat.

Fix:

  • Mode at creation: the socket is created with exactly its mode (the umask during the synchronous bind, verified on 1.3.9 and 1.4.2), so nothing is ever chmoded. Its identity is recorded in the same tick.
  • Publication and retirement: publication links only while the staging name is still that socket, and retirement removes it only while it is. Each check and its operation run back to back, synchronously.
  • Failure close: the helper itself does not remove an entry that is not this socket, or cannot be proven to be. On failure it is moved to a holding name while the server closes, so the runtime's close-time unlink by name cannot reach it, then put back by no-clobber link with the same inode, bytes and mode. The scope and residuals are listed below; this is not an unconditional guarantee.
  • Test seams: documented afterBind/afterLink hooks (production passes none) let tests inject the exact windows.
  • Owner challenge: its chmodOwnerSocket dependency becomes an afterOwnerSocketPublish seam, keeping its preparation-failure test.
  • Private-bind fixture: now requires the socket to be created private with mode 0600 and no socket name to be chmoded.

Window controls, all passing on Bun 1.3.9 and 1.4.2:

  • ambiguous partial bind: a foreign same-uid socket at the staging name keeps its inode and mode, and still serves;
  • replacement after bind: refused; the foreign file keeps its inode, bytes and mode (never chmoded, adopted or removed, including across the helper's own server close);
  • replacement after publication: publication succeeds and the retirement keeps the foreign file.

Mutation check: five mutations fail these controls on both versions:

  • an unproven same-uid socket unlinked;
  • a chmod by path after bind;
  • the identity recorded after the post-bind window;
  • an unconditional staging unlink;
  • a plain close without moving an unproven entry aside.

Verification: 18 socket-related test files 143/143 on 1.3.9 and on 1.4.2; full TypeScript suite on 1.3.9: 1,773 tests, 1,706 pass, 0 fail; frontend acceptance controls pass; privacy ok; no new host crash reports.

Residuals: see the scope section below.

Third review: holding-name move and explicit scope (83571a61)

Fix: the failure close moved an unproven staging entry to one random holding name. If that name was occupied, the close went ahead unprotected, and a holding entry created between the check and the rename was overwritten. The entry is now moved with link(2), which never replaces a holding entry, over a bounded list of 8 fresh holding names, and the staging name is dropped only while it is still the linked entry.

New control (Bun 1.3.9 and 1.4.2): the first holding name is pre-occupied. It keeps its bytes, mode and inode; the staging entry goes to the next free name, survives the helper's close, and comes back unchanged.

Mutations:

  • a clobbering rename to the first holding name fails the control on both versions;
  • giving up after an occupied first name fails on 1.4.2. On 1.3.9 it is not observable, because that runtime's close never unlinks.

Scope, stated plainly (also in the helper's documentation). The staging-name guarantees are best-effort within a directory private to this user. They cover accidental and concurrent entries, not an adversarial process of the same user. Residuals:

  • Each check and its operation run back to back, but by path; an entry changed between them is outside the guarantee.
  • If the staging entry cannot be moved aside (every bounded holding name occupied, or an entry that cannot be hard-linked, such as a directory), the failure close proceeds and the runtime's close-time unlink can remove it.
  • After a successful return the staging name is retired. The runtime's normal close later unlinks that name, and can remove an entry placed there afterwards.

The public endpoint guarantees are unchanged: the endpoint is published without replacing an existing file, removed only by identity, and never chmoded by path.

Verification: 18 socket-related test files 144/144 on 1.3.9 and on 1.4.2; full TypeScript suite on 1.3.9: 1,774 tests, 1,707 pass, 0 fail; privacy ok; no new host crash reports.

@linear-code

linear-code Bot commented Sep 30, 2026

Copy link
Copy Markdown

HACK-1211

@blacksmith-sh

This comment has been minimized.

hack-cli-tests added 3 commits September 30, 2026 12:39
A review of #109 found two startup ownership gaps.

The native HTTPS owner discarded the identity listenPublishedUnixSocket
returned and only recorded one after lstat, chmod and lstat of the path.
A failure after publication then skipped listener and endpoint cleanup,
and a replacement in that window could be chmodded or recorded as the
endpoint. The owner now keeps the published identity at once, compares
its first observation against it, and never changes the endpoint by path.
Failure cleanup always closes the listener, which can no longer unlink
anything but the retired staging name, and then removes the endpoint only
while it is this listener's inode; a replacement is kept.

The helper awaited listen() outside its cleanup, so a listen that bound
the staging name and then failed could leak that socket and listener.
Listening is now inside the cleanup, and a failed start removes only a
staging socket this user created. The helper also sets the endpoint mode
(0600) on the staging inode before publication, so the MCP backend, the
HTTPS owner and the owner challenge no longer chmod the public path.

Controls: bind-then-fail, mode-preparation failure, an endpoint at the
AF_UNIX path limit (and one byte over: refused cleanly on Bun 1.3.9,
bound in full on 1.4.2, never truncated), and an owner fault or
replacement between publication and first observation, on Bun 1.3.9 and
1.4.2.
The private-bind fixture hooked chmod on the public mcp.sock to prove the
socket is private before its mode is set and that the creation mask is
restored. Since the endpoint's mode is now set on its staging socket
before publication, the hook never fired and the check failed. It now
observes that staging chmod with the same privacy and mask checks,
requires mode 0600, and fails if the published endpoint is ever chmodded
by path.
Stand-in invocations ran concurrently (a foreground `up` while the driver
polls `ps`) and each did an unlocked read-modify-write of state.json. A
`ps` that loaded the state before the second `up` saved its foreground
token then saved its stale copy over it, so `up` saw its token gone and
exited 0 before readiness; lost call records failed the acceptance test
as well. That failed 26 of 40 local runs, and Runtime state models on CI.

Each invocation now holds an exclusive flock across its read-modify-write
and releases it only before its long waits (the foreground loop and the
ps-hang fault). 40 of 40 local runs pass.
hack-cli-tests added 2 commits September 30, 2026 13:31
…a socket

A second review of the Unix-socket publication found three staging-path
windows where an entry this attempt could not prove was its own could be
changed or removed:

- After an ambiguous partial bind (no recorded identity), cleanup
  unlinked any same-uid socket at the staging name.
- The mode was set by path after an awaited lstat, so a replacement in
  that gap could be chmodded before the postcheck refused it.
- On success the staging name was unlinked unconditionally after the
  awaited link and endpoint lstat.

The socket is now created with exactly its mode (the umask during the
synchronous bind), so nothing is ever chmodded, and its identity is
recorded in the same tick. Publication links only while the staging name
is still that socket, and retirement removes it only while it is (check
and removal back to back). An entry that is not this socket, or cannot be
proven to be, is never removed: on failure it is moved aside while the
server closes, so the runtime's close-time unlink by name cannot reach
it, and then put back with the same inode.

The owner challenge's chmod dependency becomes an afterOwnerSocketPublish
test seam, and the private-bind fixture now requires the socket to be
created private with mode 0600 and no socket name to be chmodded.
…ng entry

The failure close moved an unproven staging entry to one random holding
name: if that name was occupied the close went ahead unprotected, and a
holding entry created between the check and the rename was overwritten.
The entry is now moved with link(2), which never replaces a holding
entry, over a bounded list of fresh holding names, and the staging name
is dropped only while it is still the linked entry. An entry that cannot
be moved aside (every holding name occupied, or not hard-linkable) leaves
the close to proceed, now documented as a residual.

The helper's documentation now states its scope: accidental and
concurrent entries in a private directory, not an adversarial process of
the same user, with the remaining path-based and post-return residuals.
@roodboi
roodboi merged commit f00a647 into next Oct 1, 2026
9 checks passed
@roodboi
roodboi deleted the claude/hack-1211-socket-startup-ownership branch October 1, 2026 16:56
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