Skip to content

fix(coding-agent): windows daemon endpoint ownership and peer identity - #2175

Draft
kevinjosethomas wants to merge 1 commit into
mainfrom
eng-5340-windows-daemon-pipe
Draft

fix(coding-agent): windows daemon endpoint ownership and peer identity#2175
kevinjosethomas wants to merge 1 commit into
mainfrom
eng-5340-windows-daemon-pipe

Conversation

@kevinjosethomas

@kevinjosethomas kevinjosethomas commented Sep 9, 2026

Copy link
Copy Markdown
Member

Context

Linear: ENG-5340 — https://linear.app/primeintellect/issue/ENG-5340

On Windows the daemon listened on the constant named pipe \\.\pipe\prime-agent-daemon. Every ownership and occupancy safeguard used on Unix (0700 socket dir, 0600 socket, lockfile lease, dev/ino identity) was skipped on win32, the supervisor marked every public connection authenticated, and the client trusted the unauthenticated daemon_hello before sending create with config and launchEnv (every environment variable, including provider API keys). Any process that pre-created the pipe received the client's environment; a second same-account daemon could not tell that the pipe was already occupied.

Root cause: a fixed endpoint name, no occupancy check, and no peer identity before sensitive fields are sent.

Changes

  • daemon-socket.ts: the Windows pipe is \\.\pipe\prime-agent-daemon-<key>, key = sha256(domain\username, agent dir)[0:16]; prepareDaemonSocketPath on win32 probes the endpoint and refuses to start (Daemon socket already in use) when anything answers on it.
  • New daemon-endpoint-identity.ts: owner-only shared secret <agent dir>/daemon-endpoint-secret (32 bytes, created with wx + 0600, mode repaired on load), HMAC-SHA256 proofs bound to role + both nonces, and daemonEndpointIdentityRequired() (true on win32, or PRIME_AGENT_DAEMON_REQUIRE_ENDPOINT_IDENTITY=1 elsewhere).
  • Protocol (schema revision 28, capability endpoint_identity): daemon_hello gains endpointChallenge and endpointHandshakeRequired; new control-plane command endpoint_handshake { nonce, proof } -> { proof }. Compatibility map, plane map, read-only set, and schema id updated.
  • Supervisor: publishes the challenge; when identity is required, every other command from an unverified connection fails with Endpoint handshake required and the socket is closed; a wrong proof fails and closes.
  • DaemonClient: when identity is required (or the hello says so), one handshake per connection runs before the first command and before parked commands are replayed after reconnect. A wrong daemon proof rejects with DaemonPeerIdentityError and drops the connection. A daemon without the capability is refused for everything except list and shutdown (what the launcher needs to retire a stale daemon).
  • collectDaemonLaunchEnv keeps the full environment; its doc comment now states why (the worker must reproduce the client's shell: PATH, HOME, provider keys and base URLs, proxies, locale) and that it must only be sent after peer identity is established.
  • Docs: docs/daemon.md (new "Endpoint Ownership and Peer Identity" section), docs/windows.md. Changelog fragment added.

Compatibility classification

  • Hello fields and endpoint_handshake: capability-gated (endpoint_identity, schema 28). Old clients ignore the new hello fields; new clients check the capability before sending the handshake.
  • Unix default: wire behaviour unchanged. The handshake is only exercised when required.
  • Windows (or opt-in): new daemon / old client is an explicit incompatibility — the old client gets Endpoint handshake required and is disconnected. New client / old daemon: DaemonCapabilityUnavailableError("endpoint_identity") for anything but list/shutdown; ensureInteractiveDaemonRunning already treats such a daemon as stale (schema id differs) and retires it when idle, so interactive startup self-heals. Both directions are covered by tests.

No model-facing surface (tool names, system prompt, kernel API) changes.

Validation

Local (worktree, macOS): npm run check clean; test/daemon-endpoint-identity.test.ts (20), test/daemon-endpoint-identity-process.test.ts (1), plus daemon-client, daemon-protocol, daemon-socket, daemon-supervisor-admission, daemon-worker-windows-timeouts, daemon-launch, daemon-client-env, daemon-mode, daemon-supervisor-*, daemon-worker-connect, daemon-routed-client, daemon-peer-transport, agent-connection-daemon, package-self-update-daemon, fire-and-forget-protocol: all passing.

Prime Sandbox dxc5m4hujv50v3a1e1y2gsjt (node:24-bookworm, Debian 12, Node 24.18.0, ordinary user tester, synthetic markers only, deleted afterwards). Unix sockets stand in for the pipe; PRIME_AGENT_DAEMON_REQUIRE_ENDPOINT_IDENTITY=1 switches on the Windows policy. A squatter (fake-daemon.mjs) owns the endpoint, replays a current-looking hello, and records every command it receives.

Step Tree What Result
A1 main @ 427ea4c DaemonClient connect -> hello -> create (as main.ts does) squatter received create with config.apiKey and 24 launchEnv vars incl. OPENAI_API_KEY=sk-synthetic-...: reproduced
A2 main real CLI -p "say hi" --daemon-socket <squatter> squatter received create with 26 env vars incl. all synthetic keys: reproduced
B1 branch same as A1, squatter claims endpoint_identity + challenge client sent only endpoint_handshake; DaemonPeerIdentityError: ... invalid endpoint proof. No session data was sent; no marker reached the squatter
B2 branch same, squatter is an "old daemon" (no capability) DaemonCapabilityUnavailableError: does not support endpoint_identity; squatter received nothing
B3 branch real CLI against squatter claiming current CLI exits 1 with DaemonPeerIdentityError; squatter saw only endpoint_handshake
B4 branch real supervisor (--mode daemon) with the policy secret created -rw------- tester; hello carries endpoint_identity, challenge, endpointHandshakeRequired: true; honest client: handshake then create succeeds (worker spawned); wrong-secret client: Endpoint handshake failed; main-tree (legacy) client: Endpoint handshake required, disconnected
C1 branch, no flag Unix default against a daemon that does not require the handshake create sent as before (wire behaviour unchanged)
D branch vitest: 9 daemon test files 147 passed

On Linux the filesystem would stop a cross-account squatter from owning the socket; the sandbox demonstrates the wire-level trust problem that exists on Windows regardless of the DACL.

Native Windows verification still required

Prime Sandboxes are Linux-only, so nothing here ran on Windows. To close the ticket, on a Windows host with two local accounts:

  1. Start prime-agent as user A; confirm daemon ps / logs show \\.\pipe\prime-agent-daemon-<16 hex> and %USERPROFILE%\.prime\agent\daemon-endpoint-secret exists and is not readable by user B.
  2. As user B, pre-create a pipe with user A's name (e.g. a small Node net.createServer().listen(pipe)), then start prime-agent as user A: expect Daemon socket already in use from the daemon and a DaemonPeerIdentityError (no create, no environment) from the client if B's server replays a hello.
  3. As user B, connect to A's live pipe and send list: expect Endpoint handshake required and disconnect (also record whether the default pipe DACL lets B connect at all).
  4. Start a second daemon as user A on the same pipe: expect Daemon socket already in use.
  5. Interactive start with a pre-fix daemon still running: expect the stale daemon to be retired when idle and startup to proceed (list/shutdown path).
  6. Two accounts with the same username on different domains, and a custom PRIME_AGENT_CODING_AGENT_DIR, get distinct pipe names.

Not done here: SID-based naming (the key uses USERDOMAIN\username, from os.userInfo()); worker pipes (prime-agent-worker-*) still rely on the per-worker worker_auth token rather than the new handshake.

Note

Fix Windows daemon endpoint ownership and peer identity verification

  • Windows daemon default pipes now use a per-user key derived from domain, OS username, and agent directory instead of a global fixed name.
  • Daemon startup probes the configured Windows named pipe and refuses to proceed if another live process already occupies it.
  • Adds a mutual peer identity handshake using a local owner-only endpoint secret, preventing commands with session data or credentials from being sent to unverified daemons.
  • Risk: Windows daemons require this handshake by default. Non-Windows platforms can opt-in via an environment flag. Protected commands are withheld from unverified daemons during reconnects, but list and shutdown remain available to retire legacy daemons.

Macroscope summarized 3fdae5b.

… on Windows

Name the Windows named pipe per user and agent directory, refuse to start on an
occupied endpoint, and require an HMAC handshake over the owner-only agent-dir
secret before the supervisor accepts or the client sends any command on Windows
(opt-in elsewhere via PRIME_AGENT_DAEMON_REQUIRE_ENDPOINT_IDENTITY). Adds the
capability-gated endpoint_identity surface (schema revision 28) and documents
why the full launch environment is forwarded.

Linear: ENG-5340
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Prime Agent performance — completed

PR 3fdae5b8 compared with main f9c7e06b.
↓ improved · ↑ regressed · ≈ no clear change · — unavailable

Metric Main This PR Change Change % Result
Cold startup 4,074.7 ms 3,033.2 ms ↓ -1,041.5 ms -25.56% faster
Warm startup 2,555.3 ms 1,835.4 ms ≈ -719.9 ms -28.17% no clear change
Installation 28.85 s 26.22 s ≈ -2.64 s -9.14% no clear change
Compressed release artifacts 11.10 MB 11.12 MB ≈ +0.02 MB +0.22% no clear change
Installed footprint 597.35 MB 597.44 MB ≈ +0.09 MB +0.02% no clear change
Idle memory, summed RSS 1,127.48 MB 1,097.32 MB ≈ -30.16 MB -2.68% no clear change

Python runtime

Metric Main This PR Change Change % Result
Python kernel startup 178.6 ms 133.4 ms ↓ -45.2 ms -25.31% faster
Python cell round trip 0.848 ms 0.613 ms ↓ -0.235 ms -27.66% faster
Empty bash command 18.4 ms 13.3 ms ↓ -5.1 ms -27.52% faster
Bash git status 26.2 ms 19.2 ms ↓ -7.1 ms -26.97% faster
Bash 32 KiB output 18.6 ms 13.6 ms ↓ -4.9 ms -26.62% faster
35 cells / 9 shell calls 261.9 ms 191.1 ms ↓ -70.8 ms -27.04% faster
Python interrupt to done 1.828 ms 1.750 ms ≈ -0.078 ms -4.28% no clear change
Python state snapshot 42.4 ms 26.7 ms ↓ -15.6 ms -36.93% faster
Python state restore 511.5 ms 386.4 ms ↓ -125.1 ms -24.45% faster
Python idle RSS 34.66 MB 34.73 MB ≈ +0.07 MB +0.19% no clear change
Python RSS after pandas workload 95.84 MB 97.64 MB ≈ +1.80 MB +1.88% no clear change

Sandbox cost: ~$0.0960 — no inference calls.
Run, logs, and downloadable raw results

Methodology and samples

Main resolved at 2026-09-09T23:47:36.757046+00:00. Harness f9c7e06b.
Linux x64, 4 vCPU, 8 GB RAM, 20 GB disk; region us.
Image: node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2.
Stock tools, skills, daemon, and Python bootstrap enabled; fresh homes and a fixed Git fixture.
Onboarding is dismissed; the editor starts without a selected model or submitted prompt.
Medians shown. Arrows require a 20% timing/memory change plus absolute floors and IQR.
These practical noise floors are not a statistical significance test.
Cold means stopped Prime processes; OS filesystem caches are not flushed.
No model requests or credentials. Installation excludes build/setup time.
Installer tarballs use loopback; npm/Python downloads use the network with fresh caches.
Artifact size counts release tarballs; footprint after first use includes registry packages.
MB is decimal. Summed RSS can double-count shared pages; PSS is recorded when available.
Provisioning, setup, and build durations are recorded separately in the raw results.
Kernel probes use the installed JSONL runtime, outside the TUI/TypeScript host.
Per trial: 50 Python cells, 5 calls per shell case, and one 35-cell mix (9 git status calls).
Cell/shell values are batch means; other runtime timings are single operations.
State fixture: a 10,000-row × 8-column integer DataFrame and a 10,000-integer list.
Restore runs in a fresh kernel, including pandas imports; kernel startup is excluded.
Kernel RSS covers the isolated Python process; loaded RSS follows the pandas workload.
Costs estimate full sandbox lifetimes at configured rates, including setup and build.
Budget target: $1; not a billing cap. Checks are informational.

Metric Main successful/attempted PR successful/attempted Main spread PR spread
Cold startup 10/10 10/10 IQR 679.4 ms IQR 146.8 ms
Warm startup 10/10 10/10 IQR 808.2 ms IQR 136.0 ms
Installation 3/3 3/3 range 2.21 s range 1.12 s
Compressed release artifacts 1/1 1/1
Installed footprint 1/1 1/1
Idle memory, summed RSS 10/10 10/10 IQR 31.04 MB IQR 113.77 MB
Python kernel startup 10/10 10/10 IQR 8.6 ms IQR 10.3 ms
Python cell round trip 10/10 10/10 IQR 0.163 ms IQR 0.081 ms
Empty bash command 10/10 10/10 IQR 2.2 ms IQR 1.9 ms
Bash git status 10/10 10/10 IQR 3.1 ms IQR 1.3 ms
Bash 32 KiB output 10/10 10/10 IQR 3.9 ms IQR 1.1 ms
35 cells / 9 shell calls 10/10 10/10 IQR 27.8 ms IQR 18.9 ms
Python interrupt to done 10/10 10/10 IQR 0.250 ms IQR 0.313 ms
Python state snapshot 10/10 10/10 IQR 12.7 ms IQR 1.8 ms
Python state restore 10/10 10/10 IQR 41.9 ms IQR 60.7 ms
Python idle RSS 10/10 10/10 IQR 3.10 MB IQR 4.35 MB
Python RSS after pandas workload 10/10 10/10 IQR 3.08 MB IQR 4.28 MB

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