@junghanacs/entwurf 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/BASELINE.md +11 -4
  2. package/CHANGELOG.md +323 -0
  3. package/DELIVERY.md +34 -0
  4. package/LICENSES/node-gyp.txt +24 -0
  5. package/README.md +20 -6
  6. package/VERIFY.md +23 -4
  7. package/docs/setup-clean-host.md +43 -2
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -9
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-socket-path.js +56 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +25 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +14 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +14 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +21 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/win32-native.js +182 -0
  19. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
  20. package/mcp/entwurf-bridge/src/index.ts +38 -9
  21. package/mcp/entwurf-bridge/tsconfig.build.json +7 -0
  22. package/native/win32-x64/entwurf_win.node +0 -0
  23. package/native/win32-x64/manifest.json +45 -0
  24. package/package.json +5 -3
  25. package/pi-extensions/entwurf-control.ts +6 -1
  26. package/pi-extensions/lib/control-socket-path.js +56 -1
  27. package/pi-extensions/lib/entwurf-control-rpc.ts +32 -0
  28. package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
  29. package/pi-extensions/lib/meta-session.ts +14 -0
  30. package/pi-extensions/lib/remote-pair-receive.ts +184 -0
  31. package/pi-extensions/lib/remote-pair-send.ts +325 -0
  32. package/pi-extensions/lib/remote-pair-transport.ts +81 -0
  33. package/pi-extensions/lib/socket-discovery.ts +12 -1
  34. package/pi-extensions/lib/socket-probe.ts +20 -0
  35. package/pi-extensions/lib/win32-native.js +198 -0
  36. package/run.sh +149 -1
  37. package/scripts/check-entwurf-self-address.ts +15 -4
  38. package/scripts/check-gate-qualification.ts +8 -1
  39. package/scripts/check-remote-pair-installed.ts +242 -0
  40. package/scripts/lib/remote-pair-installed-audit.ts +25 -0
  41. package/scripts/mutants/control-send-receipt.json +2 -2
  42. package/scripts/mutants/pack-install.json +28 -2
  43. package/scripts/mutants/remote-pair-display.json +53 -0
  44. package/scripts/mutants/remote-pair-installed-audit.json +28 -0
  45. package/scripts/mutants/remote-pair-receive.json +126 -0
  46. package/scripts/mutants/remote-pair-roundtrip.json +83 -0
  47. package/scripts/mutants/remote-pair-send.json +194 -0
  48. package/scripts/mutants/remote-pair-transport.json +66 -0
  49. package/scripts/mutants/win32-preview.json +215 -0
  50. package/scripts/remote-pair-receive.ts +73 -0
@@ -18,7 +18,8 @@ evidence split, and its three states must not collapse into "macOS is supported"
18
18
  ACP turn, mux. Wiring may be written; that is not a rail receipt. The remaining
19
19
  unmeasured Darwin doctor reason is per-process environment DISCOVERY
20
20
  (start-key is portable).
21
- - **UNSUPPORTED** — native Windows only. macOS is never this.
21
+ - **UNSUPPORTED** — native Windows `setup` and its harness rails. macOS is never this.
22
+ An experimental native-Windows Pi preview (core 0.39.0) exists outside `setup`; see [BASELINE](../BASELINE.md#release-host-baseline).
22
23
 
23
24
  ## Requirements
24
25
 
@@ -242,7 +243,8 @@ message. These are harness tools, not shell subcommands; pi exposes them through
242
243
  Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The bridge has eight tools;
243
244
  pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving six active.
244
245
  Tool-call ingress is MCP; delivery to a pi citizen is still its record-addressed
245
- Unix control socket. A boot/listing from `check-bridge` is not a model-turn or
246
+ control address (a Unix socket; a named pipe on the experimental native-Windows Pi
247
+ preview). A boot/listing from `check-bridge` is not a model-turn or
246
248
  native-rail acceptance.
247
249
 
248
250
  To inspect the ACP provider from a project-local package instead of a global one:
@@ -447,6 +449,34 @@ The owned source is formatted at upstream width (`biome.json` override, indentWi
447
449
  per-file exception. A formatting change to an owned file changes its emitted bytes. So it is
448
450
  re-emitted with `--write`, and its generated digest is reviewed like any other carrier change.
449
451
 
452
+ ## 2c. Optional remote pair between two hosts (#107)
453
+
454
+ This lets one declared pair of existing citizens on two hosts reach each other over SSH that you already run. [DELIVERY](../DELIVERY.md#remote-pair-107-one-declared-pair-across-two-hosts) owns the contract. The machine boundary is set by a person, and nothing here is automatic.
455
+
456
+ 1. **Connect the hosts yourself.** Use a private network (Tailscale or similar is recommended) and SSH keys and authorization you already trust. From each host, `ssh -o BatchMode=yes <alias> true` must succeed non-interactively, and the other host's non-interactive `command -v entwurf` must resolve to the installation you intend. Entwurf edits no SSH config, key, known-hosts file or network setting. Whatever SSH itself writes under your own policy, such as host-key updates, stays yours.
457
+ 2. **Run the accepted bytes on both hosts, in the processes that matter.** See [applying new source to a running host](#applying-new-source-to-a-running-host). A pulled checkout or a green setup line does not, by itself, show what a running bridge loaded.
458
+ 3. **Write one file on each host,** at `remotePairOptInPath()`. That is the pi agent dir unless `ENTWURF_META_SESSIONS_DIR` moves the meta-record store. Check that the sending bridge's environment and the SSH receive environment resolve the same path. Use mode `0600` and never overwrite a file you did not inspect:
459
+
460
+ ```json
461
+ {
462
+ "remoteGardenId": "<the other host's garden id>",
463
+ "ssh": "<your existing alias for the other host>",
464
+ "localGardenId": "<this host's garden id that talks to it>",
465
+ "remotePairFile": "<absolute path of the other host's own file>"
466
+ }
467
+ ```
468
+
469
+ The two files mirror each other: one host's `remoteGardenId` is the other's `localGardenId`. The other host's `remotePairFile` must spell this path as it is normalized, not through a symlinked alias.
470
+
471
+ To check a file, read it with the repository's internal parser from a source checkout. Use the same agent/store-root settings the bridge runs with. This is a read-only dev-clone check: there is no installed operator validation verb, and it performs no send and no model input.
472
+
473
+ ```bash
474
+ node --experimental-strip-types -e 'import("./pi-extensions/lib/remote-pair-send.ts").then(m=>console.log(m.readRemotePairOptIn(m.remotePairOptInPath())))'
475
+ ```
476
+
477
+ Do not validate the configuration by sending a test message.
478
+ 4. **Use it only between siblings you have open.** The measured self-fetch pair must be active and armed, and the target rail's own admission still applies. Delivery never starts, resumes or wakes an OFF citizen. To an active, armed one it rings the normal doorbell, which may start a turn at once in the receiving TUI. Remove the file to switch the pair off; there is nothing to stop or reconnect.
479
+
450
480
  ## 3. Optional Claude Code native citizen
451
481
 
452
482
  This rail is CERTIFIED on Linux; on macOS it is NOT CERTIFIED — pending physical
@@ -1126,6 +1156,17 @@ run `pnpm install` when the lockfile changed and `pnpm run build-bridge` before
1126
1156
  artifact-consuming checks such as `./run.sh check-bridge-delivery`; ignored dist
1127
1157
  may be missing or stale. Installed mode never bootstraps pnpm in `node_modules`.
1128
1158
 
1159
+ ### Applying new source to a running host
1160
+
1161
+ New source on disk is not a fresh runtime. A pi-durable host spawns the compiled bridge `mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js` even from a checkout (the bootstrap's `PACKAGED_BRIDGE_ENTRY`). Ordinary `start.sh` in a checkout runs the TypeScript source instead. An already-running bridge keeps the modules it loaded. So, for a host that has to run new bridge code:
1162
+
1163
+ 1. Build once from the checkout (`pnpm run build-bridge`, or its body `bash scripts/with-dist-lock.sh bash scripts/build-bridge.sh` when no package lifecycle should run).
1164
+ 2. Compare the whole compiled output with the accepted candidate's, as a file-set and hash manifest. A hash of the selected entry alone is not enough: the entry can stay identical while a helper it loads differs. Check the pinned SDK set separately, and that the new process started after the build.
1165
+ 3. Close the visible session and reopen it with its own native same-id resume.
1166
+ 4. Confirm `entwurf_self` names the same garden id, and that the receiver/owner join points at the new process, whose bridge child started after the build.
1167
+
1168
+ A per-component setup PASS or a `git pull` does not show which bytes a running bridge loaded. On 2026-10-20 one host of the #107 pair still selected a pre-feature dist after a pull and a green setup. The cause was not traced. A hash check caught it before the first message, and a manual build and visible resume fixed it. Entwurf adds no auto-build, reload or watcher for this.
1169
+
1129
1170
  ## 8. Release acceptance versus host acceptance
1130
1171
 
1131
1172
  - `entwurf check-bridge`: installed MCP bytes boot; no backend auth.
@@ -84,6 +84,10 @@ import { probeNativeSenderAlive, reconcileSenderIdentityClaims, resolveCodexRequ
84
84
  import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaIdentityByGardenId, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "../../../pi-extensions/lib/meta-session.js";
85
85
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.js";
86
86
  import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
87
+ import { makeLocalTraceFact, readRemotePairOptIn, remotePairOptInPath, runEntwurfV2WithRemoteRoute, } from "../../../pi-extensions/lib/remote-pair-send.js";
88
+ import { processTransport } from "../../../pi-extensions/lib/remote-pair-transport.js";
89
+ import { CONTROL_SOCKET_DIR } from "../../../pi-extensions/lib/socket-discovery.js";
90
+ import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.js";
87
91
  const HOME = os.homedir();
88
92
  // ============================================================================
89
93
  // OMP root policy — FIRST, before any lazy default-root consumer (#87 B1).
@@ -110,6 +114,14 @@ catch (err) {
110
114
  // Directory SOURCE is this adapter's own policy — the bridge honours an explicit
111
115
  // ENTWURF_DIR override the pi side does not. The path GRAMMAR is the shared leaf.
112
116
  const ENTWURF_DIR = process.env.ENTWURF_DIR ?? defaultControlSocketDir(HOME);
117
+ /** Is this session's own control address up? A socket file's presence on Linux/macOS. On
118
+ * Windows (#78) the address is a named pipe, which has no file to look at and must not be
119
+ * opened by a stat (that connects): the owner-validated probe answers instead. */
120
+ async function controlAddressPresent(socketPath) {
121
+ if (process.platform === "win32")
122
+ return (await probeSocketLiveness(socketPath)) === "alive";
123
+ return existsSync(socketPath);
124
+ }
113
125
  // ============================================================================
114
126
  // Live control-socket discovery for entwurf_peers lives in the TS fact-provider
115
127
  // (pi-extensions/lib/entwurf-fact-provider.ts → listEntwurfFacts), which the
@@ -171,7 +183,7 @@ class EntwurfSenderIdentityError extends Error {
171
183
  "the send is then marked external/non-replyable).");
172
184
  }
173
185
  }
174
- function buildStrictPiSenderEnvelope() {
186
+ async function buildStrictPiSenderEnvelope() {
175
187
  const sessionId = process.env.PI_SESSION_ID?.trim();
176
188
  const agentId = process.env.PI_AGENT_ID?.trim();
177
189
  const cwd = process.cwd();
@@ -195,7 +207,7 @@ function buildStrictPiSenderEnvelope() {
195
207
  const socketPath = controlSocketPathIn(ENTWURF_DIR, sessionId);
196
208
  const self = computeSelfAddressability({
197
209
  origin: "pi-session",
198
- socketAlive: existsSync(socketPath),
210
+ socketAlive: await controlAddressPresent(socketPath),
199
211
  socketPathComputable: true,
200
212
  });
201
213
  return {
@@ -258,7 +270,7 @@ async function buildMetaSenderEnvelope(identity, cwd, trustedMarker) {
258
270
  async function resolveAuthoritativeSender(context, cwd = process.cwd()) {
259
271
  const sessionId = process.env.PI_SESSION_ID?.trim();
260
272
  const agentId = process.env.PI_AGENT_ID?.trim();
261
- const pi = sessionId && agentId && cwd ? buildStrictPiSenderEnvelope() : null;
273
+ const pi = sessionId && agentId && cwd ? await buildStrictPiSenderEnvelope() : null;
262
274
  const marker = resolveTrustedMetaSenderIdentity({
263
275
  markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
264
276
  });
@@ -422,11 +434,22 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
422
434
  // Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
423
435
  // + the mailbox body sender share one envelope. No replyability gate (see above).
424
436
  const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
425
- const rendered = await runAndRenderEntwurfV2FromSurface({ target, intent, message, mode, wants_reply },
426
- // No trust-preflight inputs are passed, and none exist to pass: the preflight on this
427
- // path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
428
- // the whole options surface now.
429
- { senderProvider: () => sender });
437
+ // #107: the operator's remote-pair opt-in is read per send. Absent = off and the local
438
+ // surface below runs exactly as before; a declared target goes over that one route.
439
+ const rendered = await runEntwurfV2WithRemoteRoute({ target, intent, message, mode, wants_reply }, {
440
+ senderProvider: () => sender,
441
+ route: readRemotePairOptIn(remotePairOptInPath()),
442
+ // The decider's canonical socket dir AND this bridge's ENTWURF_DIR: a trace in either is local.
443
+ localTrace: makeLocalTraceFact({
444
+ sessionsDir: defaultMetaSessionsDir(),
445
+ controlSocketDirs: [CONTROL_SOCKET_DIR, ENTWURF_DIR],
446
+ }),
447
+ transport: processTransport,
448
+ // No trust-preflight inputs are passed, and none exist to pass: the preflight on this
449
+ // path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
450
+ // the whole options surface now.
451
+ runLocal: (params, opts) => runAndRenderEntwurfV2FromSurface(params, opts),
452
+ });
430
453
  return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
431
454
  }
432
455
  catch (err) {
@@ -463,7 +486,7 @@ server.tool("entwurf_self", "Return this caller's authoritative identity envelop
463
486
  // live socket). The old code synthesized the path and printed it as if
464
487
  // it existed — a lie when the session has no --entwurf-control (SE-1).
465
488
  const socketPath = controlSocketPathIn(ENTWURF_DIR, sender.sessionId);
466
- const socketState = existsSync(socketPath) ? "alive" : "expected";
489
+ const socketState = (await controlAddressPresent(socketPath)) ? "alive" : "expected";
467
490
  extra.socketPath = socketPath;
468
491
  extra.socketState = socketState;
469
492
  lines.push(socketState === "alive"
@@ -24,8 +24,17 @@
24
24
  * adapter keeps its own root policy: the pi side derives from HOME, the MCP
25
25
  * bridge honours `ENTWURF_DIR`. What is unified is the grammar, not the policy.
26
26
  *
27
- * Keep dependency-free except `node:path`.
27
+ * Windows (#78) has no socket files: the address is a named pipe,
28
+ * `\\.\pipe\entwurf-<sha256(canonRoot(dir))[:16]>-<gardenId>`. The digest keeps two
29
+ * store roots on one host apart without putting a path in the pipe namespace; it is not
30
+ * a new address axis (still the garden id plus the store root). The inverse grammar has
31
+ * no Windows half because nothing scans the pipe namespace: candidates come from records.
32
+ *
33
+ * Keep dependency-free except node builtins (`node:path`, and `node:crypto`/`node:fs`
34
+ * for the Windows root canonicalisation only).
28
35
  */
36
+ import { createHash } from "node:crypto";
37
+ import * as fs from "node:fs";
29
38
  import * as path from "node:path";
30
39
  /** Control-socket filename suffix. The stem is a bare garden id. */
31
40
  export const CONTROL_SOCKET_SUFFIX = ".sock";
@@ -47,8 +56,54 @@ export function defaultControlSocketDir(home) {
47
56
  * @returns {string}
48
57
  */
49
58
  export function controlSocketPathIn(dir, gardenId) {
59
+ if (process.platform === "win32")
60
+ return win32PipeNameFor(dir, gardenId);
50
61
  return path.join(dir, `${gardenId}${CONTROL_SOCKET_SUFFIX}`);
51
62
  }
63
+ /** Every Windows control pipe this package names starts here. */
64
+ export const WIN32_PIPE_PREFIX = "\\\\.\\pipe\\entwurf-";
65
+ /**
66
+ * The one spelling of a Windows store root, so Pi A, Pi B, the bridge and peer-facts
67
+ * derive the same pipe name from differently written paths. The nearest EXISTING ancestor
68
+ * of `path.win32.resolve(dir)` goes through `realpath` (8.3 names expanded, case from the
69
+ * filesystem); the components below it are appended as written. Separators become `\`,
70
+ * there is no trailing separator, and the whole string is lower-cased. Nothing is created
71
+ * or touched, so the control dir need not exist yet. A failure other than ENOENT throws.
72
+ *
73
+ * @param {string} dir
74
+ * @param {(p: string) => string} [realpath]
75
+ * @returns {string}
76
+ */
77
+ export function canonRootWin32(dir, realpath = fs.realpathSync.native) {
78
+ let head = path.win32.resolve(dir);
79
+ /** @type {string[]} */
80
+ const tail = [];
81
+ for (;;) {
82
+ try {
83
+ const joined = path.win32.join(realpath(head), ...tail);
84
+ return joined.replace(/[\\/]+$/, "").toLowerCase();
85
+ }
86
+ catch (err) {
87
+ const parent = path.win32.dirname(head);
88
+ if ( /** @type {{ code?: unknown }} */(err).code !== "ENOENT" || parent === head)
89
+ throw err;
90
+ tail.unshift(path.win32.basename(head));
91
+ head = parent;
92
+ }
93
+ }
94
+ }
95
+ /**
96
+ * Forward grammar on Windows: `\\.\pipe\entwurf-<16 hex>-<gardenId>`.
97
+ *
98
+ * @param {string} dir
99
+ * @param {string} gardenId
100
+ * @param {(p: string) => string} [realpath]
101
+ * @returns {string}
102
+ */
103
+ export function win32PipeNameFor(dir, gardenId, realpath) {
104
+ const digest = createHash("sha256").update(canonRootWin32(dir, realpath), "utf8").digest("hex").slice(0, 16);
105
+ return `${WIN32_PIPE_PREFIX}${digest}-${gardenId}`;
106
+ }
52
107
  /**
53
108
  * Inverse grammar: a socket filename back to its garden id, or `null` when the
54
109
  * name does not carry the suffix.
@@ -14,6 +14,7 @@
14
14
  * asked to open, and touches nothing pi-specific — it is a pure transport over a path.
15
15
  */
16
16
  import * as net from "node:net";
17
+ import { win32Native } from "./win32-native.js";
17
18
  /**
18
19
  * THE `<sender_info>` synthesis — the one place the sender envelope becomes
19
20
  * message text. ONE consumer since the visible-first cut (#50 C3):
@@ -101,6 +102,8 @@ export async function fetchControlSocketRuntimeInfo(socketPath, options = {}) {
101
102
  }
102
103
  export async function sendRpcCommand(socketPath, command, options = {}) {
103
104
  const { timeout = 5000 } = options;
105
+ if (process.platform === "win32")
106
+ return await sendPipeRpcCommand(socketPath, command, timeout);
104
107
  return new Promise((resolve, reject) => {
105
108
  const socket = net.createConnection(socketPath);
106
109
  socket.setEncoding("utf8");
@@ -173,3 +176,25 @@ export async function sendRpcCommand(socketPath, command, options = {}) {
173
176
  });
174
177
  });
175
178
  }
179
+ /**
180
+ * The Windows client (#78). The addon makes one owner-validated connect, writes the request
181
+ * line and returns the first response line, all inside one deadline; a timeout is
182
+ * `ETIMEDOUT`, a pipe owned by anyone but this token's expected owner is `EPERM` before any
183
+ * byte. The protocol is request-bound on its own connection, so that first line IS the
184
+ * answer: anything other than this command's response is refused, never waited past.
185
+ */
186
+ async function sendPipeRpcCommand(pipeName, command, timeout) {
187
+ const { responseLine } = await win32Native().pipeRpc(pipeName, JSON.stringify(command), Math.max(0, Math.floor(timeout)));
188
+ let msg;
189
+ try {
190
+ msg = JSON.parse(responseLine.trim());
191
+ }
192
+ catch {
193
+ throw new Error(`control pipe answered ${command.type} with a line that is not JSON`);
194
+ }
195
+ const response = msg;
196
+ if (response?.type !== "response" || response.command !== command.type) {
197
+ throw new Error(`control pipe answered something other than the ${command.type} response`);
198
+ }
199
+ return { response };
200
+ }
@@ -60,13 +60,19 @@ export function formatMetaMailboxBody(sender, message, wantsReply) {
60
60
  const replyable = sender.replyable === true;
61
61
  const isMeta = sender.origin === "meta-session";
62
62
  const kind = isMeta ? "meta-session, " : "";
63
+ // A remote sender is never replyable here: no reply route was probed. Its cwd is a path on the
64
+ // SENDING host, so it is neither abbreviated against this host's HOME nor shown as a local path.
65
+ const remote = !replyable ? sender.remote : undefined;
63
66
  const sessionLine = replyable
64
67
  ? `${sender.sessionId} (${kind}replyable — reply via entwurf_v2 to this sessionId, intent=fire-and-forget)`
65
- : isMeta
66
- ? `${sender.sessionId} (meta-session, non-replyable)`
67
- : `${sender.sessionId} (external, non-replyable)`;
68
+ : remote
69
+ ? `${sender.sessionId} (remote via ${remote.via}; ${remote.replyRoute === "declared" ? "reply route declared, not probed" : "no reply route declared"})`
70
+ : isMeta
71
+ ? `${sender.sessionId} (meta-session, non-replyable)`
72
+ : `${sender.sessionId} (external, non-replyable)`;
73
+ const where = remote ? `${sender.cwd} (sender-host path)` : abbreviateHome(sender.cwd);
68
74
  return (`[entwurf received ⟵]\n` +
69
- ` from: ${sender.agentId} @ ${abbreviateHome(sender.cwd)}\n` +
75
+ ` from: ${sender.agentId} @ ${where}\n` +
70
76
  ` session: ${sessionLine}\n` +
71
77
  ` at: ${formatKstTimestamp(sender.timestamp)}\n` +
72
78
  ` wants reply: ${wantsReply ? "yes" : "no"}\n` +
@@ -52,6 +52,7 @@
52
52
  import { execFileSync } from "node:child_process";
53
53
  import * as crypto from "node:crypto";
54
54
  import * as fs from "node:fs";
55
+ import { createRequire } from "node:module";
55
56
  import * as os from "node:os";
56
57
  import * as path from "node:path";
57
58
  import { generateSessionId, SESSION_ID_RE } from "./session-id.js";
@@ -845,6 +846,8 @@ const O_NONBLOCK_FLAG = typeof fs.constants.O_NONBLOCK === "number" ? fs.constan
845
846
  * Shaping a message is the job of the caller that knows what the answer is FOR.
846
847
  */
847
848
  export function readStoreRecordFile(file) {
849
+ if (process.platform === "win32")
850
+ return win32RecordReader()(file).toString("utf8");
848
851
  if (O_NOFOLLOW_FLAG === 0) {
849
852
  throw new MetaRecordError(`cannot read meta-record ${path.basename(file)} safely: this platform exposes no O_NOFOLLOW, so a record ` +
850
853
  "read cannot refuse a symlink swapped in at the final component. entwurf certifies Linux only.");
@@ -863,6 +866,17 @@ export function readStoreRecordFile(file) {
863
866
  fs.closeSync(fd);
864
867
  }
865
868
  }
869
+ /**
870
+ * Windows has no `O_NOFOLLOW` (libuv defines it as 0), so the same three decisions — a
871
+ * final-component reparse point refused as `ELOOP`, a non-regular entry as
872
+ * `ENTWURF_ENOTREG`, both on the handle the read uses — are made by the preview addon
873
+ * (#78). It is reached only here and only on win32, through `createRequire`, so the
874
+ * installers that copy this file without its siblings never load it on Linux or macOS.
875
+ */
876
+ function win32RecordReader() {
877
+ const native = createRequire(import.meta.url)("./win32-native.js");
878
+ return native.win32ReadNoFollow;
879
+ }
866
880
  /** The reader every store-WIDE scan pairs with {@link readActiveStoreEntries}. The
867
881
  * caller's entry kind decides the POLICY — whether a non-regular entry is a defect
868
882
  * (certification), a diagnostic (listing) or a non-candidate (the rival scan) — because
@@ -0,0 +1,123 @@
1
+ /**
2
+ * remote-pair-receive — the RECEIVING half of #107's one declared remote pair (rail 1).
3
+ *
4
+ * The host that owns the target record runs this once per SSH call. It reads one JSON claim
5
+ * from stdin, checks it against the operator-written pair declaration, and hands a matching
6
+ * claim to the existing v2 dispatch. The record, owner and deliverability checks stay the
7
+ * dispatch's own: nothing here writes a mailbox, resolves a rail, or retries.
8
+ *
9
+ * Trust model (GLG, #107 D3 (a)): the boundary is the same operator's SSH account. The pair
10
+ * check is a SCOPE limit, not authentication: a claim whose `from`/`target` is not the declared
11
+ * pair is refused before any mutation. It does not detect that same account sending the
12
+ * declared gid falsely; nothing here claims to.
13
+ *
14
+ * Sender: the stdin claim is the only sender input. Ambient carriers (`PI_SESSION_ID`,
15
+ * `PI_AGENT_ID`, sender markers) in the SSH environment are never consulted, so this module
16
+ * does not import the authoritative-sender resolver at all.
17
+ *
18
+ * Display (rail 2b): the envelope keeps the existing `external-mcp` / non-replyable origin, so no
19
+ * receiver, control renderer or self-address table learns a new origin value. A `remote` field
20
+ * carries the route label and whether a route back is declared; the mailbox body renders it as
21
+ * `remote via <route>; reply route declared, not probed` (or `no reply route declared`), with the
22
+ * sender's cwd marked as a sender-host path. The pilot's display proof is the self-fetch mailbox
23
+ * body only: whether another rail (a live control socket, native-push injection) shows the same
24
+ * label is NOT PROVEN here, and nothing in this lane unifies the display across rails.
25
+ */
26
+ import { requireGardenId } from "./meta-session.js";
27
+ const MESSAGE_CAP = 16000;
28
+ /** Every Unicode control character (the class herdr-fresh-call.ts refuses). A header field is
29
+ * rendered unescaped into the body's `[entwurf received]` block, so a newline in one could forge a
30
+ * `session:` or `wants reply:` line. The message body keeps its newlines. */
31
+ const CONTROL_CHAR = /\p{Cc}/u;
32
+ export class RemotePairInputError extends Error {
33
+ }
34
+ function parseObject(text, what) {
35
+ let raw;
36
+ try {
37
+ raw = JSON.parse(text);
38
+ }
39
+ catch (err) {
40
+ throw new RemotePairInputError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
41
+ }
42
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
43
+ throw new RemotePairInputError(`${what}: must be a JSON object`);
44
+ }
45
+ return raw;
46
+ }
47
+ function requireExactKeys(obj, keys, what) {
48
+ const got = Object.keys(obj).sort();
49
+ const want = [...keys].sort();
50
+ if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
51
+ throw new RemotePairInputError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
52
+ }
53
+ }
54
+ function requireString(obj, key, what) {
55
+ const v = obj[key];
56
+ if (typeof v !== "string" || v.length === 0) {
57
+ throw new RemotePairInputError(`${what}: "${key}" must be a non-empty string`);
58
+ }
59
+ return v;
60
+ }
61
+ /** A field that lands in the rendered sender header: non-empty and free of control characters. */
62
+ function requireHeaderString(obj, key, what) {
63
+ const v = requireString(obj, key, what);
64
+ if (CONTROL_CHAR.test(v)) {
65
+ throw new RemotePairInputError(`${what}: "${key}" must not contain control characters`);
66
+ }
67
+ return v;
68
+ }
69
+ export function parseRemoteClaim(text) {
70
+ const what = "remote claim";
71
+ const obj = parseObject(text, what);
72
+ requireExactKeys(obj, ["from", "fromAgentId", "fromCwd", "target", "message", "wantsReply"], what);
73
+ const message = requireString(obj, "message", what);
74
+ if (message.length > MESSAGE_CAP) {
75
+ throw new RemotePairInputError(`${what}: "message" exceeds ${MESSAGE_CAP} chars`);
76
+ }
77
+ if (typeof obj.wantsReply !== "boolean") {
78
+ throw new RemotePairInputError(`${what}: "wantsReply" must be a boolean`);
79
+ }
80
+ return {
81
+ from: requireGardenId(obj.from),
82
+ fromAgentId: requireHeaderString(obj, "fromAgentId", what),
83
+ fromCwd: requireHeaderString(obj, "fromCwd", what),
84
+ target: requireGardenId(obj.target),
85
+ message,
86
+ wantsReply: obj.wantsReply,
87
+ };
88
+ }
89
+ /** The scope check. Null = inside the declared pair. */
90
+ export function checkRemotePair(claim, decl) {
91
+ if (claim.from !== decl.remoteGardenId) {
92
+ return {
93
+ reason: "remote-pair-from-mismatch",
94
+ detail: `from ${claim.from} is not the declared remote ${decl.remoteGardenId}`,
95
+ };
96
+ }
97
+ if (claim.target !== decl.localGardenId) {
98
+ return {
99
+ reason: "remote-pair-target-mismatch",
100
+ detail: `target ${claim.target} is not the declared local ${decl.localGardenId}`,
101
+ };
102
+ }
103
+ return null;
104
+ }
105
+ export function buildRemoteSenderEnvelope(claim, decl, now, replyRoute = "none") {
106
+ return {
107
+ sessionId: claim.from,
108
+ agentId: claim.fromAgentId,
109
+ cwd: claim.fromCwd,
110
+ timestamp: now.toISOString(),
111
+ origin: "external-mcp",
112
+ replyable: false,
113
+ remote: { via: decl.ssh, replyRoute },
114
+ };
115
+ }
116
+ export async function receiveRemoteClaim(claim, decl, deps) {
117
+ const mismatch = checkRemotePair(claim, decl);
118
+ if (mismatch)
119
+ return { kind: "rejected", ...mismatch };
120
+ const sender = buildRemoteSenderEnvelope(claim, decl, (deps.now ?? (() => new Date()))(), deps.replyRoute);
121
+ const rendered = await deps.dispatch({ target: claim.target, intent: "fire-and-forget", message: claim.message, wants_reply: claim.wantsReply }, sender);
122
+ return { kind: "dispatched", isError: rendered.isError, text: rendered.text };
123
+ }