Skip to content

feat(host): make the host/client transport an interface, not a MessagePort#28

Open
jamesyong-42 wants to merge 8 commits into
mainfrom
feat/explorer-channel
Open

feat(host): make the host/client transport an interface, not a MessagePort#28
jamesyong-42 wants to merge 8 commits into
mainfrom
feat/explorer-channel

Conversation

@jamesyong-42

Copy link
Copy Markdown
Member

Summary

  • Introduces ExplorerChannel (SPEC §9) so the host and client depend on an ordered, reliable, message-oriented pipe rather than on MessagePort specifically. MessagePort becomes the compatibility path: attachPort wraps attachChannel, connectFileExplorer wraps connectFileExplorerChannel, and PortFileExplorer accepts either shape. Wire format unchanged; no consumer migration.
  • Fixes two things found while consolidating: adaptPort was duplicated verbatim in host.ts and client-port.ts, and its Node MessagePort branch carried a no-op removeEventListener (TODO(7.10)) that let a detached session keep observing messages. The channel now gates delivery on its own state.
  • Ships session context/policy types with the attach signature so the Truffle server can be written against it. Nothing enforces them yet — permission tables are PR 3.

This is PR 1 of the remote-workspace sequence in planning/REMOTE_WORKSPACE_PLAN.md. No network code, no behavior change.

Test Plan

  • New packages/mille/test/channel-contract.test.mjs covers CH-001…CH-008 per SPEC §24.1, including 10,000 messages arriving in send order, close idempotency, post-close delivery gating (asserted by posting straight at the raw port), a throwing listener not killing the channel, and a dead transport closing with TRANSPORT_ERROR.
  • pnpm -r --if-present test — mille 359 (354 pass, 5 pre-existing skips, 0 fail), mille-ui 470/470, playground 29/29.
  • pnpm -r --if-present typecheck clean.
  • Changed files pass prettier --check. Note src/react.ts and src/undo.ts fail it on untouched code, so root format:check is red independent of this branch — left alone rather than bulking the diff.
  • CI (this PR) — including the Windows job, which is the one that has not run this code path before.

🤖 Generated with Claude Code

CLAUDE.md records the architecture, the exact build/test commands, and the
gotchas that cost time to rediscover — tests importing from dist/, the
mandatory --exclude mille-binding, the dangling SPEC/PLAN citations, and
why panic = "unwind" is load-bearing.

planning/REMOTE_WORKSPACE_PLAN.md sequences draft/mille-truffle-spec.md
into shippable PRs, validated against Truffle v0.7.6 (09367f6) rather than
the spec's assumptions. Two findings there change the security design:
mesh.net listeners are tailnet-authenticated but app-unauthenticated, so
mille's authorize() is the only application gate and the Tailscale grant is
required rather than advisory; and authorization must key on the Tailscale
node id, because the RFC 017 device_id arrives in the peer's own hello
envelope and is self-declared.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
…ePort

Both sides only ever needed an ordered, reliable, message-oriented pipe with
a close notification, but that requirement was never named — postMessage
calls and port lifecycle were spread through host.ts and client-port.ts.
They now talk through ExplorerChannel (SPEC §9), so a framed Node Duplex,
and through it a remote workspace, can be dropped in without either side
learning anything about the transport.

MessagePort is the compatibility path, not a special case: attachPort(port)
wraps attachChannel(channel, context) and connectFileExplorer(port) wraps
connectFileExplorerChannel(channel). PortFileExplorer accepts either shape,
duck-typed, so `new PortFileExplorer(port)` is unchanged. The wire format is
untouched and no consumer needs to migrate.

Two defects fell out of the consolidation. adaptPort was duplicated verbatim
in the host and the client — it is now one function. And its Node MessagePort
branch has always carried a no-op removeEventListener with a TODO(7.10),
which meant a detached session could still observe messages; the channel now
gates delivery on its own state, so a closed channel delivers nothing even
when the underlying listener cannot be detached. The contract test proves it
by posting straight at the raw port after close.

A closed channel also rejects the client's in-flight calls and emits a
connection event instead of leaving callers awaiting forever, while the last
mirror snapshot stays readable (§18.3). Session context and policy types
ship with the attach signature so the Truffle server can be written against
it, but nothing enforces them yet — the permission tables are PR 3.

channel-contract.test.mjs covers CH-001 through CH-008 per SPEC §24.1,
including 10,000 messages arriving in send order and a dead transport
closing with TRANSPORT_ERROR. Full suite green: mille 359 (354 pass, 5
pre-existing skips), mille-ui 470, playground 29, workspace typecheck clean.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
The git remote is vibecook-dev/mille, so the spec's target was right and
the plan had it backwards. The jamesyong-42/mille references in the package
manifests and README still resolve via GitHub's post-transfer redirect —
they are stale, not broken. Cargo.toml:14 is the one that actually 404s.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
PR 1 named the transport an interface; this implements the one that is not
a MessagePort. `@vibecook/mille/node` exports framed-stream host and client
channels that run the existing protocol over any Node Duplex — which is what
makes a remote workspace possible, since a Truffle mesh socket is a Duplex
with a tailnet-verified peer identity attached.

The wire format is a 20-byte header (MLLE, big-endian, versioned major and
minor) plus UTF-8 JSON metadata and raw attachments. Snapshots and deltas
already carry bincode buffers, so JSON-encoding them would inflate every
payload; instead each binary view becomes a {$mille:'bin',i} placeholder and
its bytes are appended verbatim. Views honour byteOffset/byteLength, so a
10-byte subarray of a 1000-byte buffer ships 10 bytes — getting that wrong
would silently hand the peer the wrong data, so it has its own test.

The decoder validates the header before allocating: a frame claiming 4 GiB
of attachments is rejected having buffered only the 20 bytes that arrived.
Fragmentation is arbitrary in both directions, and a property test
re-partitions a stream at random cut points to prove it. A fuzzer mutates
every byte of a valid frame and asserts the only failure mode is a clean
FrameProtocolError — never a TypeError or an allocation from a trusted
length.

Writes queue in the channel rather than in the stream, one frame handed over
at a time and nothing further after write() returns false (SPEC §20.1). So
bufferedBytes measures what we hold, not what the OS took, and the hard
limit closes the channel instead of growing without bound.

Two things the tests taught me, both fixed here rather than worked around.
resolveLimits rejected lowering one bound without restating its dependent,
which is backwards — a remote policy may only ever reduce a maximum, so an
unspecified inner bound now clamps to follow the outer one and only an
explicitly incoherent pair throws. And the channel used to drop its 'error'
listener before tearing the stream down, so a dying Duplex emitted an error
with nothing attached and Node threw it as an uncaught exception; the
listener is now swapped for a logging sink, never removed.

entry-boundaries.test.mjs records two facts worth pinning: node:stream is a
type-only import, so the framed channel has no runtime Node dependency at
all and will accept any duck-typed Duplex; and the package root is Node-only
by design via the native loader, so ./port and ./react are the browser-safe
entries, not the barrel.

Suite green: mille 399 (394 pass, 5 pre-existing skips), mille-ui 470,
playground 29, workspace typecheck clean.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
PR 1 shipped ExplorerSessionPolicy as a type that gated nothing. This is the
enforcement, and it is what makes attaching an untrusted peer defensible:
every mutation and call is authorized before native dispatch, so a permissive
client, a future transport, or a bug in either cannot route around it.

The matrix lives in two lookup tables in src/channel/policy.ts rather than in
conditionals threaded through the dispatch switch. That is deliberate — "which
of the forty entry points did we forget to gate" should be answerable by
reading one file, and the policy test walks the whole table so a missing entry
fails rather than silently allowing.

Read-only sessions get EROFS on writes. The host-global controls — undo,
projection settings, workspace roots, workspace resync, copyFromPath, and
client-pushed decorations — are denied to remote sessions by default and
opened only by explicit per-flag grant, because each changes what every other
session sees. attachPort still means local-admin, so in-process consumers are
unaffected.

Three things fell out that are worth naming on their own.

Transfer progress was broadcast to every session. OP_PROGRESS detail carries
source and destination paths, so any attached window could watch what every
other one was copying. Progress now routes to the session that owns the
operation, and non-operation warnings stay global.

Operation ids are claimed per session. A second session naming an in-flight id
is refused EEXIST; cancelling another session's operation is refused
indistinguishably from cancelling one that does not exist, so an unprivileged
peer cannot probe for live ids. Claims release on success, failure, completion
and session close.

Capability masking needed a reader. A read-only session is told Readonly and
not ReadWrite/Trash/AtomicWrite so a UI can disable write affordances instead
of failing at EROFS — but PortFileExplorer had no way to ask, which would have
made the masking decorative. capabilities() now exists.

Also: entry resync is rate-limited to 10/min per session on a sliding window,
per-session so a noisy peer cannot starve a neighbour; readFile/writeFile carry
Uint8Array instead of expanding every byte into a JSON number, with both sides
accepting either form so old and new peers interoperate; and ErrorCode gains
EFBIG for the size limit PR 4 will enforce.

Suite green: mille 413 (408 pass, 5 pre-existing skips), mille-ui 470,
playground 29, workspace typecheck clean.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
Ran the remote-workspace thesis against a real tailnet before writing any of
the Truffle package: two ephemeral mesh nodes, a genuine FileExplorerHost
behind mesh.net.createServer, a genuine PortFileExplorer over
mesh.net.connect. Eight checks pass, including PR 3's policy holding across
the real transport and a 512 KiB payload round trip in 22 ms.

The identity capture is the part worth keeping. remotePeer.deviceId is null
on a raw TCP accept, so authorizing on the RFC 017 ULID — which this plan
briefly recommended — would have meant authorizing on null. The Tailscale
StableID is what actually arrives. That is now measured rather than inferred
from source comments.

Does not discharge AC-002: two stacks in one process exercise the sidecar,
WireGuard and framing, but not NAT traversal, DERP or wide-area latency.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
Adds @vibecook/mille-truffle (server half). serveMille binds a Truffle
mesh.net listener, runs the open handshake, authorizes the peer, and attaches
the accepted socket to a FileExplorerHost — so a client on another machine
drives the same explorer API it uses locally, with no per-file network RPC.

Exports are named and server-defined. A client asks for "work", never for a
path, which is why traversal is not a request-time concern: roots are
canonicalized once at startup and a misconfigured export fails the service at
boot rather than surfacing as a confusing denial on someone's first connect.

The ordering inside an accepted connection is the security property. The
verified Tailscale peer id is readable synchronously at accept, and no host,
no engine and no filesystem handle exists until authorization has passed —
there is a test asserting hostCount stays zero for every denial path. An
unknown export and a forbidden one return byte-identical rejections, or the
service enumerates its own exports. A socket with no verified identity is
refused, an authorize callback that throws is a denial, and read-write on a
read-only export is rejected rather than quietly downgraded, because a silent
downgrade hides a misconfiguration from whoever wrote it.

One host is shared per distinct export configuration, fingerprinted on roots
and explorer options and explicitly not on identity: who you are decides
whether you may attach, never which engine you attach to. It stays warm for
five minutes after its last session so a reconnect keeps the same EntryIds.

Truffle is reached only through a structural MeshLike interface, so the suite
runs on a fake mesh with no tailnet and no native addon. That fake cannot
prove the interface matches a real MeshNode, so serveMille was also run
against a live tailnet — two ephemeral nodes, a denied export refused with no
host created, an authorized peer browsing and mutating, and PR 3's policy
still returning EACCES for host-global calls across the wire. Ten checks.

That run caught a defect the fake could not. mille's default initialWalk is
'full', which is a documented no-op meaning "the consumer calls
populateFromRoots itself". An in-process embedder does; a remote peer cannot,
so every served workspace would have sat empty forever. Served exports now
default to 'roots-only', which is also the right shape for a large remote
tree, and a regression test drives a real client to assert the tree is not
empty.

Also fixes ExplorerSessionContext, shipped in PR 1 with optional fields
declared `?:` but without `| undefined`. Under exactOptionalPropertyTypes
those differ, and every real caller builds the context from values that are
already `string | undefined`.

The package is private for now — `pnpm -r publish` would otherwise pick it up,
and it ships experimentally with the release wiring in PR 6.

Suite green: mille 413, mille-truffle 19, mille-ui 470, playground 29,
workspace typecheck clean.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
All three JS jobs enumerate the packages they build rather than using
`pnpm -r build`, because that would trigger mille's release napi build. The
new package was not on the list, so its dist never existed and every job died
with ERR_MODULE_NOT_FOUND on `mille-truffle/dist/index.js`.

Reproduced locally by removing the dist directory — same error, three
occurrences — and confirmed green again after rebuilding.

The enumeration is a standing trap: the next package added will fail the same
way. Left as-is here rather than switching to `pnpm -r build`, which would
cost every JS job a full LTO release compile of the native binary.

Claude-Session: https://claude.ai/code/session_01BxUdtf4tecfv4uLAqRa1CR
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