@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
@@ -0,0 +1,259 @@
1
+ /**
2
+ * remote-pair-send — the SENDING half of #107's one declared remote pair (rail 2a).
3
+ *
4
+ * It sits in front of the local v2 decider, at the surface. With no route declaration, or a target
5
+ * the declaration does not name, the local path runs unchanged. When the declaration names the
6
+ * target, any local trace of that gid (a record entry in any state, or anything at its control
7
+ * socket path) fails closed as an ambiguity before transport. Otherwise the call runs over one
8
+ * transport call: no synthetic record, no new backend, no new rail plan, and no retry.
9
+ *
10
+ * Sender: only the caller's authoritative envelope becomes the claim. It must be the declared local
11
+ * citizen; an anonymous envelope or another gid is refused before transport. The model chooses
12
+ * neither host nor command: both come from the operator's declaration.
13
+ *
14
+ * Remote command: ssh joins the remote words into one shell string. So the alias and the remote
15
+ * declaration path are held to a narrow POSIX-safe charset that needs no quoting, and the command
16
+ * is the fixed `entwurf remote-pair-receive --pair <path>`. There is no quoting engine here.
17
+ *
18
+ * Receipt: a result is decided only when the exit code and the one stdout JSON line agree
19
+ * (see {@link classifyRemoteReceipt}). Everything else is `remote-indeterminate`: the other host
20
+ * may or may not have enqueued. Read and answer stay unknown until a reply arrives.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { controlSocketPathIn } from "./control-socket-path.js";
25
+ import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.js";
26
+ import { inspectControlSocketPath } from "./socket-discovery.js";
27
+ export class RemoteRouteDeclarationError extends Error {
28
+ }
29
+ const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
30
+ const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
31
+ const CONNECT_TIMEOUT_SECONDS = 10;
32
+ export const REMOTE_SEND_TIMEOUT_MS = 30_000;
33
+ const PAIR_REJECT_REASONS = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
34
+ export function parseRemoteRouteDeclaration(text) {
35
+ const what = "remote route declaration";
36
+ let raw;
37
+ try {
38
+ raw = JSON.parse(text);
39
+ }
40
+ catch (err) {
41
+ throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
42
+ }
43
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
44
+ throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
45
+ }
46
+ const obj = raw;
47
+ const want = ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"];
48
+ const got = Object.keys(obj).sort();
49
+ if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
50
+ throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
51
+ }
52
+ const { ssh, remotePairFile } = obj;
53
+ if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
54
+ throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
55
+ }
56
+ if (typeof remotePairFile !== "string" ||
57
+ !REMOTE_PATH.test(remotePairFile) ||
58
+ remotePairFile.split("/").includes("..")) {
59
+ throw new RemoteRouteDeclarationError(`${what}: "remotePairFile" must be an absolute path matching ${REMOTE_PATH} with no ".." segment`);
60
+ }
61
+ return {
62
+ remoteGardenId: requireGardenId(obj.remoteGardenId),
63
+ ssh,
64
+ localGardenId: requireGardenId(obj.localGardenId),
65
+ remotePairFile,
66
+ };
67
+ }
68
+ /** The one argv the transport runs. Every word is inside the no-quoting charset above. */
69
+ export function remoteReceiveArgv(route) {
70
+ return [
71
+ "ssh",
72
+ "-o",
73
+ "BatchMode=yes",
74
+ "-o",
75
+ `ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
76
+ route.ssh,
77
+ "entwurf",
78
+ "remote-pair-receive",
79
+ "--pair",
80
+ route.remotePairFile,
81
+ ];
82
+ }
83
+ function tail(s) {
84
+ return s.length > 400 ? s.slice(-400) : s;
85
+ }
86
+ /**
87
+ * Decide only on agreement. 0 + dispatched/!isError = accepted; 3 + rejected(pair reason) = pair
88
+ * refused; 4 + dispatched/isError = the other host reported an error while processing (its own text
89
+ * says what is known, e.g. `retry-safe: false` after a possible write; this line infers neither
90
+ * delivery nor non-delivery); 2 + invalid = it refused our
91
+ * input. Any other exit, a signal, a timeout, no line, more than one line, non-JSON, or a JSON
92
+ * kind/field that disagrees with the exit is indeterminate.
93
+ */
94
+ export function classifyRemoteReceipt(r) {
95
+ const indeterminate = (why) => ({
96
+ kind: "remote-indeterminate",
97
+ why,
98
+ exitCode: r.exitCode,
99
+ stderrTail: tail(r.stderr),
100
+ });
101
+ if (r.timedOut)
102
+ return indeterminate("timed out");
103
+ if (r.signal !== null)
104
+ return indeterminate(`ended by signal ${r.signal}`);
105
+ if (r.exitCode === null) {
106
+ return indeterminate(r.stderr.startsWith("spawn failed")
107
+ ? "the transport did not start (spawn failed)"
108
+ : "did not exit (no exit code)");
109
+ }
110
+ if (![0, 2, 3, 4].includes(r.exitCode))
111
+ return indeterminate(`exit ${r.exitCode}`);
112
+ const nl = r.stdout.indexOf("\n");
113
+ if (nl === -1 || nl !== r.stdout.length - 1)
114
+ return indeterminate("stdout is not exactly one line");
115
+ let j;
116
+ try {
117
+ j = JSON.parse(r.stdout);
118
+ }
119
+ catch {
120
+ return indeterminate("stdout is not JSON");
121
+ }
122
+ if (j === null || typeof j !== "object" || Array.isArray(j))
123
+ return indeterminate("stdout is not a JSON object");
124
+ const o = j;
125
+ const keys = Object.keys(o).sort().join(",");
126
+ if (r.exitCode === 0 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === false) {
127
+ if (typeof o.text === "string")
128
+ return { kind: "remote-accepted", text: o.text };
129
+ }
130
+ if (r.exitCode === 4 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === true) {
131
+ if (typeof o.text === "string")
132
+ return { kind: "remote-reported-error", text: o.text };
133
+ }
134
+ if (r.exitCode === 3 && keys === "detail,kind,reason" && o.kind === "rejected") {
135
+ if (typeof o.reason === "string" && PAIR_REJECT_REASONS.includes(o.reason) && typeof o.detail === "string") {
136
+ return { kind: "remote-pair-refused", reason: o.reason, detail: o.detail };
137
+ }
138
+ }
139
+ if (r.exitCode === 2 && keys === "detail,kind" && o.kind === "invalid" && typeof o.detail === "string") {
140
+ return { kind: "remote-invalid", detail: o.detail };
141
+ }
142
+ return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
143
+ }
144
+ function render(route, o) {
145
+ const via = `via ${route.ssh}`;
146
+ switch (o.kind) {
147
+ case "remote-accepted":
148
+ return {
149
+ isError: false,
150
+ text: `entwurf_v2 remote ${via} → accepted by the other host (its fact; read/answer unknown until a reply arrives; paths below are on the other host)\n${o.text}`,
151
+ };
152
+ case "remote-reported-error":
153
+ // The other host's dispatch returned isError. That can follow a write (an enqueue whose
154
+ // stamp or signal then threw is `retry-safe: false`), so this header claims neither
155
+ // non-delivery nor safety to resend: the other host's own text below is the fact.
156
+ return {
157
+ isError: true,
158
+ text: `entwurf_v2 remote ${via} → the other host reported an error while processing; read its receipt below, delivery is not inferred from this line; not retried (paths below are on the other host)\n${o.text}`,
159
+ };
160
+ case "remote-pair-refused":
161
+ return {
162
+ isError: true,
163
+ text: `entwurf_v2 remote ${via} → refused by the other host's pair: ${o.reason} (${o.detail})`,
164
+ };
165
+ case "remote-invalid":
166
+ return { isError: true, text: `entwurf_v2 remote ${via} → the other host refused the input: ${o.detail}` };
167
+ case "remote-indeterminate":
168
+ return {
169
+ isError: true,
170
+ text: `entwurf_v2 remote ${via} → indeterminate: ${o.why}. The other host may or may not have enqueued; ` +
171
+ `not retried.${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
172
+ };
173
+ }
174
+ }
175
+ function refuse(text) {
176
+ return { isError: true, text: `entwurf_v2 rejected: ${text}` };
177
+ }
178
+ export async function runEntwurfV2WithRemoteRoute(params, opts) {
179
+ const route = opts.route;
180
+ if (route === null || params.target !== route.remoteGardenId) {
181
+ return opts.runLocal(params, { senderProvider: opts.senderProvider });
182
+ }
183
+ if (await opts.localTrace(route.remoteGardenId)) {
184
+ return refuse(`remote-route-local-conflict: ${route.remoteGardenId} is declared remote ${`via ${route.ssh}`} but this host also has a local trace of it; not choosing`);
185
+ }
186
+ const sender = opts.senderProvider();
187
+ if (!sender || sender.origin === "external-mcp" || sender.sessionId !== route.localGardenId) {
188
+ return refuse(`remote-sender-not-declared: only ${route.localGardenId} may send over this route (sender: ${sender ? sender.sessionId : "none"})`);
189
+ }
190
+ const claim = {
191
+ from: sender.sessionId,
192
+ fromAgentId: sender.agentId,
193
+ fromCwd: sender.cwd,
194
+ target: route.remoteGardenId,
195
+ message: params.message,
196
+ wantsReply: params.wants_reply ?? false,
197
+ };
198
+ const result = await opts.transport.run(remoteReceiveArgv(route), JSON.stringify(claim), opts.timeoutMs ?? REMOTE_SEND_TIMEOUT_MS);
199
+ return render(route, classifyRemoteReceipt(result));
200
+ }
201
+ /**
202
+ * The production local-trace fact: the store's own record-entry inspection (absent vs anything
203
+ * else — readable, unreadable, symlinked or drifted all count) and the control-socket path
204
+ * inspection, in EVERY socket dir the caller names. The bridge's `ENTWURF_DIR` can differ from the
205
+ * decider's canonical dir, so both are passed and a trace in either one means this host already
206
+ * answers to the gid.
207
+ */
208
+ export function makeLocalTraceFact(dirs) {
209
+ const socketDirs = [...new Set(dirs.controlSocketDirs.map((d) => path.resolve(d)))];
210
+ return async (gardenId) => {
211
+ if (metaRecordExistsByGardenId(gardenId, dirs.sessionsDir))
212
+ return true;
213
+ for (const dir of socketDirs) {
214
+ const inspection = await inspectControlSocketPath(controlSocketPathIn(dir, gardenId));
215
+ if (inspection.kind !== "absent")
216
+ return true;
217
+ }
218
+ return false;
219
+ };
220
+ }
221
+ /** The operator's opt-in file name. One per host; it declares that host's one remote pair. */
222
+ export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
223
+ /**
224
+ * Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
225
+ * `ENTWURF_META_SESSIONS_DIR` override relocates the store (then the file follows the store it
226
+ * pairs). Read-only reuse of the existing root resolution; nothing here writes.
227
+ */
228
+ export function remotePairOptInPath() {
229
+ return path.join(path.dirname(defaultMetaSessionsDir()), REMOTE_PAIR_OPT_IN_FILE);
230
+ }
231
+ /**
232
+ * Read the opt-in file once, at the moment of use (no cache). Absent is the ONLY "off". A
233
+ * symlink, a non-regular entry, an unreadable file or an invalid declaration throws: the operator
234
+ * opted in, so a broken opt-in is refused rather than read as off.
235
+ *
236
+ * The bridge this declares is the operator's to build and secure (for example a private network
237
+ * such as Tailscale, plus SSH authentication and permissions). Entwurf only walks the declared
238
+ * route: it installs, configures, discovers and verifies none of it, and never retries.
239
+ */
240
+ export function readRemotePairOptIn(file) {
241
+ let st;
242
+ try {
243
+ st = fs.lstatSync(file);
244
+ }
245
+ catch (err) {
246
+ if (err.code === "ENOENT")
247
+ return null;
248
+ throw err;
249
+ }
250
+ if (!st.isFile()) {
251
+ throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
252
+ }
253
+ try {
254
+ return parseRemoteRouteDeclaration(fs.readFileSync(file, "utf8"));
255
+ }
256
+ catch (err) {
257
+ throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
259
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * remote-pair-transport — the production process adapter behind #107's `RemoteTransport` (rail 2c).
3
+ *
4
+ * One child per send: `spawn(argv[0], argv.slice(1))` with no shell, stdin written once and closed,
5
+ * stdout and stderr captured separately. On the bound it signals ITS OWN child only (TERM, then KILL
6
+ * after a short grace); if the pipes are still held after one more grace, it closes its own read ends
7
+ * and settles as a timeout rather than waiting for a close that may never come. It is not a process manager: it tracks no
8
+ * other process, scans nothing, and never retries. Classification belongs to
9
+ * `classifyRemoteReceipt`; this module only reports what the child did.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ const CAPTURE_CAP = 64 * 1024;
13
+ const KILL_GRACE_MS = 2000;
14
+ const FINAL_GRACE_MS = 2000;
15
+ function capped(chunks, size, chunk) {
16
+ if (size.n >= CAPTURE_CAP)
17
+ return;
18
+ const room = CAPTURE_CAP - size.n;
19
+ const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
20
+ chunks.push(piece);
21
+ size.n += piece.length;
22
+ }
23
+ export const processTransport = {
24
+ run(argv, stdin, timeoutMs) {
25
+ return new Promise((resolve) => {
26
+ const out = [];
27
+ const err = [];
28
+ const outN = { n: 0 };
29
+ const errN = { n: 0 };
30
+ let timedOut = false;
31
+ let spawnError = null;
32
+ let settled = false;
33
+ const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
34
+ let killTimer;
35
+ let finalTimer;
36
+ const settle = (code, signal) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ clearTimeout(bound);
41
+ if (killTimer)
42
+ clearTimeout(killTimer);
43
+ if (finalTimer)
44
+ clearTimeout(finalTimer);
45
+ const stderr = Buffer.concat(err).toString("utf8");
46
+ resolve({
47
+ exitCode: spawnError ? null : code,
48
+ signal: spawnError ? null : signal,
49
+ timedOut,
50
+ stdout: Buffer.concat(out).toString("utf8"),
51
+ stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
52
+ });
53
+ };
54
+ const bound = setTimeout(() => {
55
+ timedOut = true;
56
+ child.kill("SIGTERM");
57
+ killTimer = setTimeout(() => {
58
+ child.kill("SIGKILL");
59
+ // Something else may still hold our stdout/stderr pipes (an inherited descendant,
60
+ // an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
61
+ // ends and settle as a timeout, which the receipt reads as indeterminate.
62
+ finalTimer = setTimeout(() => {
63
+ child.stdout.destroy();
64
+ child.stderr.destroy();
65
+ settle(child.exitCode, child.signalCode);
66
+ }, FINAL_GRACE_MS);
67
+ }, KILL_GRACE_MS);
68
+ }, timeoutMs);
69
+ child.stdout.on("data", (c) => capped(out, outN, c));
70
+ child.stderr.on("data", (c) => capped(err, errN, c));
71
+ child.on("error", (e) => {
72
+ spawnError = e.message;
73
+ });
74
+ // A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
75
+ child.stdin.on("error", () => { });
76
+ child.on("close", (code, signal) => settle(code, signal));
77
+ child.stdin.end(stdin);
78
+ });
79
+ },
80
+ };
@@ -51,7 +51,7 @@
51
51
  */
52
52
  import * as fs from "node:fs/promises";
53
53
  import * as os from "node:os";
54
- import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir, gardenIdFromSocketFilename, } from "./control-socket-path.js";
54
+ import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir, gardenIdFromSocketFilename, WIN32_PIPE_PREFIX, } from "./control-socket-path.js";
55
55
  import { SESSION_ID_RE } from "./session-id.js";
56
56
  import { probeSocketLiveness } from "./socket-probe.js";
57
57
  /** Canonical control-socket directory; the socket filename IS the gardenId
@@ -81,7 +81,9 @@ export function controlSocketPath(gardenId, dir = CONTROL_SOCKET_DIR) {
81
81
  * `record-less-socket` reject (a diagnostic state) — no probe, no acceptance.
82
82
  */
83
83
  export function isRecordLessSocketCandidate(inspection) {
84
- return inspection.kind === "socket-file";
84
+ // A Windows pipe name (#78) is never lstat-ed (see inspectControlSocketPath), so its
85
+ // `socket-file` carries no presence evidence and cannot confirm a record-less socket.
86
+ return inspection.kind === "socket-file" && !inspection.socketPath.startsWith(WIN32_PIPE_PREFIX);
85
87
  }
86
88
  /**
87
89
  * Inspect the EXACT control-socket path given (no gid re-derivation) and classify it by
@@ -94,6 +96,12 @@ export function isRecordLessSocketCandidate(inspection) {
94
96
  * symlink is caught as an address-conflict and never connected, P1).
95
97
  */
96
98
  export async function inspectControlSocketPath(socketPath, lstatFn = (p) => fs.lstat(p)) {
99
+ // Windows (#78): the address is a named pipe. The pipe filesystem has no symlinks to
100
+ // catch, and opening the name to stat it would CONNECT to it and consume an instance.
101
+ // So the name is never stat-ed: it is safe to probe because the probe itself refuses a
102
+ // pipe whose owner is not this token's expected owner before any byte.
103
+ if (socketPath.startsWith(WIN32_PIPE_PREFIX))
104
+ return { kind: "socket-file", socketPath };
97
105
  let st;
98
106
  try {
99
107
  st = await lstatFn(socketPath);
@@ -160,8 +168,12 @@ export async function mapInspectionToLiveness(inspection, probeSocket) {
160
168
  */
161
169
  export async function scanSocketProbes(piCitizenGardenIds, deps = {}) {
162
170
  const dir = deps.dir ?? CONTROL_SOCKET_DIR;
171
+ // Windows (#78) has no socket files to list and nothing scans the pipe namespace: the
172
+ // candidates are the record-backed citizens alone.
163
173
  const readdir = deps.readdir ??
164
174
  (async (d) => {
175
+ if (process.platform === "win32")
176
+ return [];
165
177
  const dirents = await fs.readdir(d, { withFileTypes: true });
166
178
  return dirents.map((e) => ({ name: e.name, isSymbolicLink: e.isSymbolicLink() }));
167
179
  });
@@ -28,6 +28,7 @@
28
28
  * hidden — `entwurf_peers` shows it and the v2 decider rejects on it by name.
29
29
  */
30
30
  import * as net from "node:net";
31
+ import { win32Native } from "./win32-native.js";
31
32
  export const DEFAULT_PROBE_TIMEOUT_MS = 300;
32
33
  /**
33
34
  * Pure classification of a connect-time error code into liveness. Only the two
@@ -56,6 +57,8 @@ export function shouldUnlinkOnGc(liveness) {
56
57
  */
57
58
  export async function probeSocketLiveness(socketPath, opts = {}) {
58
59
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
60
+ if (process.platform === "win32")
61
+ return await probePipeLiveness(socketPath, timeoutMs);
59
62
  return await new Promise((resolve) => {
60
63
  const socket = net.createConnection(socketPath);
61
64
  const timer = setTimeout(() => {
@@ -76,3 +79,21 @@ export async function probeSocketLiveness(socketPath, opts = {}) {
76
79
  });
77
80
  });
78
81
  }
82
+ /**
83
+ * The Windows probe (#78): the control address is a named pipe, and a connect alone would
84
+ * talk to whoever holds the name. The addon connects within one deadline and refuses a pipe
85
+ * whose owner is not this token's expected owner (`EPERM`) before any byte. Only `ENOENT`
86
+ * (no instance) is dead; a refused owner, a busy pipe past the deadline and every Win32
87
+ * failure stay indeterminate. A missing or drifted addon is not a liveness fact: that
88
+ * refusal throws.
89
+ */
90
+ async function probePipeLiveness(pipeName, timeoutMs) {
91
+ const native = win32Native();
92
+ try {
93
+ await native.pipeProbe(pipeName, Math.max(0, Math.floor(timeoutMs)));
94
+ return "alive";
95
+ }
96
+ catch (err) {
97
+ return classifyConnectError(err.code);
98
+ }
99
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * win32-native — the Windows preview's native surface (#78), and the only way to reach it.
3
+ *
4
+ * Node cannot open a file without following a final-component reparse point on Windows
5
+ * (libuv `UV_FS_O_NOFOLLOW` is 0), and it cannot check who owns a named pipe before
6
+ * talking to it. Those two capabilities come from one Entwurf-owned N-API addon,
7
+ * `native/win32-x64/entwurf_win.node`, built in CI from `native/win32/entwurf_win.c` and
8
+ * shipped beside `native/win32-x64/manifest.json`. This module verifies the manifest and
9
+ * the addon bytes BEFORE `process.dlopen`, and refuses by name otherwise:
10
+ *
11
+ * ENTWURF_WIN32_UNSUPPORTED not win32-x64, no addon in this install (an ordinary
12
+ * Linux pack), a Node without the N-API floor, or a
13
+ * verified addon this host cannot load
14
+ * ENTWURF_WIN32_NATIVE_DRIFT the manifest is malformed, or the addon bytes are not the
15
+ * bytes the manifest names
16
+ *
17
+ * There is no repair, no download and no fallback: a refusal is the whole answer.
18
+ *
19
+ * Authored as `.js` for the reason `control-socket-path.js` is: it is imported from the
20
+ * tsc-emit root program and from the strip-types lanes alike. `meta-session.ts` reaches it
21
+ * only through a win32-only `createRequire`, so the installers that copy `meta-session.ts`
22
+ * on their own are unchanged on Linux and macOS. Keep dependency-free except node builtins.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ export const WIN32_UNSUPPORTED_CODE = "ENTWURF_WIN32_UNSUPPORTED";
29
+ export const WIN32_DRIFT_CODE = "ENTWURF_WIN32_NATIVE_DRIFT";
30
+ const PACKAGE_NAME = "@junghanacs/entwurf";
31
+ const NATIVE_DIR = path.join("native", "win32-x64");
32
+ const ADDON_FILE = "entwurf_win.node";
33
+ const SHA256 = /^[0-9a-f]{64}$/;
34
+ // The addon is built against exactly this N-API version (native/win32/binding.gyp, build.mjs). A
35
+ // manifest that declares any other value describes some other build: drift, not a floor to honour.
36
+ const ADDON_NAPI_VERSION = 8;
37
+ // One addon object per process, even if two module instances of this file exist (a
38
+ // transpiling loader and Node's own loader can each evaluate it once).
39
+ const SLOT = Symbol.for("entwurf.win32-native.addon");
40
+ // DOS device names open a device, not a file, at any directory. The addon's GetFileType
41
+ // check refuses a non-disk handle anyway; this keeps the open from ever being attempted.
42
+ const DOS_DEVICE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i;
43
+ /**
44
+ * @param {string} code
45
+ * @param {string} message
46
+ * @param {unknown} [cause]
47
+ * @returns {Error & { code: string }}
48
+ */
49
+ function refusal(code, message, cause) {
50
+ const err = /** @type {Error & { code: string }} */ (new Error(message, cause === undefined ? undefined : { cause }));
51
+ err.code = code;
52
+ return err;
53
+ }
54
+ /**
55
+ * The package root, found from where THIS module lives: the source depth
56
+ * (`<root>/pi-extensions/lib/`) first, then the compiled depth
57
+ * (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`). The root is the directory whose
58
+ * `package.json` names this package; `null` when neither depth does.
59
+ *
60
+ * @param {string} [from]
61
+ * @returns {string | null}
62
+ */
63
+ export function win32NativePackageRoot(from = path.dirname(fileURLToPath(import.meta.url))) {
64
+ for (const up of [
65
+ ["..", ".."],
66
+ ["..", "..", "..", "..", ".."],
67
+ ]) {
68
+ const root = path.resolve(from, ...up);
69
+ let name;
70
+ try {
71
+ name = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))?.name;
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ if (name === PACKAGE_NAME)
77
+ return root;
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Verify `<root>/native/win32-x64/` and return the addon path to load. Pure apart from
83
+ * reading the two files, so the refusals are provable on any host.
84
+ *
85
+ * @param {string} root
86
+ * @param {{ napi?: string | undefined }} [versions]
87
+ * @returns {{ addonPath: string, sha256: string, napiVersion: number }}
88
+ */
89
+ export function verifyWin32NativeAt(root, versions = process.versions) {
90
+ const dir = path.join(root, NATIVE_DIR);
91
+ const manifestPath = path.join(dir, "manifest.json");
92
+ let raw;
93
+ try {
94
+ raw = fs.readFileSync(manifestPath, "utf8");
95
+ }
96
+ catch (err) {
97
+ if ( /** @type {{ code?: unknown }} */(err).code === "ENOENT") {
98
+ throw refusal(WIN32_UNSUPPORTED_CODE, `this entwurf install carries no Windows addon (${manifestPath} is absent): only the Windows preview ` +
99
+ "artifact runs on Windows; an ordinary pack does not.");
100
+ }
101
+ throw refusal(WIN32_DRIFT_CODE, `cannot read ${manifestPath}`, err);
102
+ }
103
+ let manifest;
104
+ try {
105
+ manifest = JSON.parse(raw);
106
+ }
107
+ catch (err) {
108
+ throw refusal(WIN32_DRIFT_CODE, `${manifestPath} is not JSON`, err);
109
+ }
110
+ if (manifest?.schema !== 1 ||
111
+ manifest.platform !== "win32" ||
112
+ manifest.arch !== "x64" ||
113
+ manifest.file !== ADDON_FILE ||
114
+ typeof manifest.sha256 !== "string" ||
115
+ !SHA256.test(manifest.sha256) ||
116
+ manifest.napiVersion !== ADDON_NAPI_VERSION) {
117
+ throw refusal(WIN32_DRIFT_CODE, `${manifestPath} does not describe one win32-x64 ${ADDON_FILE}`);
118
+ }
119
+ const napi = Number(versions.napi);
120
+ if (!(napi >= ADDON_NAPI_VERSION)) {
121
+ throw refusal(WIN32_UNSUPPORTED_CODE, `the Windows addon needs N-API ${ADDON_NAPI_VERSION}; this Node offers ${versions.napi ?? "none"}`);
122
+ }
123
+ const addonPath = path.join(dir, ADDON_FILE);
124
+ let bytes;
125
+ try {
126
+ bytes = fs.readFileSync(addonPath);
127
+ }
128
+ catch (err) {
129
+ throw refusal(WIN32_DRIFT_CODE, `the manifest names ${addonPath}, which cannot be read`, err);
130
+ }
131
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
132
+ if (sha256 !== manifest.sha256) {
133
+ throw refusal(WIN32_DRIFT_CODE, `${addonPath} is not the addon its manifest names (sha256 ${sha256} != ${manifest.sha256})`);
134
+ }
135
+ return { addonPath, sha256, napiVersion: ADDON_NAPI_VERSION };
136
+ }
137
+ /**
138
+ * @typedef {object} Win32NativeAddon
139
+ * @property {(file: string) => Buffer} readNoFollow
140
+ * @property {(name: string, line: string, timeoutMs: number) => Promise<{ responseLine: string, serverPid: number }>} pipeRpc
141
+ * @property {(name: string, timeoutMs: number) => Promise<{ serverPid: number }>} pipeProbe
142
+ * @property {() => { live: number, cancelledIncomplete: number }} ioStats
143
+ */
144
+ /** @returns {Win32NativeAddon} */
145
+ export function win32Native() {
146
+ const held = /** @type {Record<symbol, Win32NativeAddon | undefined>} */ ( /** @type {unknown} */(process))[SLOT];
147
+ if (held)
148
+ return held;
149
+ if (process.platform !== "win32" || process.arch !== "x64") {
150
+ throw refusal(WIN32_UNSUPPORTED_CODE, `the Windows native surface exists only on win32-x64, not ${process.platform}-${process.arch}`);
151
+ }
152
+ const root = win32NativePackageRoot();
153
+ if (root === null) {
154
+ throw refusal(WIN32_UNSUPPORTED_CODE, `cannot find the ${PACKAGE_NAME} package root beside this module`);
155
+ }
156
+ const { addonPath } = verifyWin32NativeAt(root);
157
+ const module = { exports: {} };
158
+ try {
159
+ process.dlopen(module, addonPath);
160
+ }
161
+ catch (err) {
162
+ throw refusal(WIN32_UNSUPPORTED_CODE, `this host cannot load the verified Windows addon ${addonPath}`, err);
163
+ }
164
+ const addon = /** @type {Win32NativeAddon} */ (module.exports);
165
+ /** @type {Record<symbol, Win32NativeAddon | undefined>} */ ( /** @type {unknown} */(process))[SLOT] = addon;
166
+ return addon;
167
+ }
168
+ /**
169
+ * Read a record's bytes on Windows, refusing a final-component reparse point (`ELOOP`) and
170
+ * a non-regular entry (`ENTWURF_ENOTREG`) on the very handle the read uses. Over 16 MiB is
171
+ * `EFBIG`; other Win32 failures are `EWIN32_<n>`.
172
+ *
173
+ * @param {string} file
174
+ * @returns {Buffer}
175
+ */
176
+ export function win32ReadNoFollow(file) {
177
+ const stem = path.win32.basename(file).split(".")[0].replace(/[ ]+$/, "");
178
+ if (DOS_DEVICE.test(stem)) {
179
+ throw refusal("ENTWURF_ENOTREG", `${path.win32.basename(file)} names a DOS device, never a record`);
180
+ }
181
+ return win32Native().readNoFollow(file);
182
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * remote-pair-receive — `entwurf remote-pair-receive --pair <absolute file>` (#107 rail 1).
4
+ *
5
+ * The receiving host's entry for one declared remote pair. The sending host runs it over one
6
+ * SSH call and writes one JSON claim to stdin. This entry reads the operator-named pair
7
+ * declaration once, applies the pair scope check, and hands a matching claim to the existing
8
+ * v2 dispatch. The scope and trust model are in pi-extensions/lib/remote-pair-receive.ts.
9
+ *
10
+ * Scope: `--pair` must name THIS host's own opt-in file (`remotePairOptInPath()`), absolute and
11
+ * normalized-equal; any other path — even a valid declaration — is exit 2. That one file is the pair
12
+ * this host accepts and, because it names the sender's host as its route, the declared route back.
13
+ * The sender's declaration carries the path only so the argv shape is explicit; it selects nothing.
14
+ *
15
+ * EXIT CONTRACT (stdout carries a JSON receipt line only where named):
16
+ * 0 dispatched, the dispatch did not report an error — receipt on stdout
17
+ * 4 dispatched, the dispatch reported an error (its text says what is known, e.g. a
18
+ * rejection or `retry-safe: false` after a possible write) — receipt
19
+ * 3 refused by the pair scope check, no dispatch ran — receipt
20
+ * 2 --pair is not this host's opt-in, the opt-in is absent/broken, or the claim is invalid; no
21
+ * dispatch ran — `{kind:"invalid"}` receipt;
22
+ * wrong argv is ALSO 2 but prints usage on stderr only, with no receipt
23
+ * 1 an uncaught dispatch/store throw (crash, don't warn) — no receipt; stderr carries the
24
+ * error. The sending host reads that as indeterminate.
25
+ */
26
+ import { readFileSync } from "node:fs";
27
+ import * as path from "node:path";
28
+ import { runAndRenderEntwurfV2FromSurface } from "../pi-extensions/lib/entwurf-v2-surface.js";
29
+ import { parseRemoteClaim, receiveRemoteClaim, } from "../pi-extensions/lib/remote-pair-receive.js";
30
+ import { readRemotePairOptIn, remotePairOptInPath } from "../pi-extensions/lib/remote-pair-send.js";
31
+ function emit(receipt, code) {
32
+ process.stdout.write(`${JSON.stringify(receipt)}\n`);
33
+ process.exit(code);
34
+ }
35
+ const args = process.argv.slice(2);
36
+ if (args.length !== 2 || args[0] !== "--pair") {
37
+ console.error("usage: entwurf remote-pair-receive --pair <this host's opt-in file> (claim JSON on stdin)");
38
+ process.exit(2);
39
+ }
40
+ const pairFile = args[1];
41
+ let decl;
42
+ let claim;
43
+ try {
44
+ // The scope is THIS host's own opt-in, never a file the sender picks: `--pair` must name it
45
+ // exactly (absolute, normalized), and that one file is read for this receive.
46
+ const own = remotePairOptInPath();
47
+ if (!path.isAbsolute(pairFile) || path.normalize(pairFile) !== own) {
48
+ throw new Error(`--pair must be this host's opt-in file ${own} (got ${pairFile})`);
49
+ }
50
+ const route = readRemotePairOptIn(own);
51
+ if (route === null)
52
+ throw new Error(`this host has no remote pair opt-in (${own} is absent)`);
53
+ decl = { remoteGardenId: route.remoteGardenId, ssh: route.ssh, localGardenId: route.localGardenId };
54
+ claim = parseRemoteClaim(readFileSync(0, "utf8"));
55
+ }
56
+ catch (err) {
57
+ emit({ kind: "invalid", detail: err instanceof Error ? err.message : String(err) }, 2);
58
+ }
59
+ const receipt = await receiveRemoteClaim(claim, decl, {
60
+ dispatch: (params, sender) => runAndRenderEntwurfV2FromSurface(params, { senderProvider: () => sender }),
61
+ // The scope file IS this host's own opt-in, and it names the sender's host as its route: a
62
+ // matching claim always has a declared route back. Declared, not probed.
63
+ replyRoute: "declared",
64
+ });
65
+ emit(receipt, receipt.kind === "rejected" ? 3 : receipt.isError ? 4 : 0);