@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,325 @@
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
+
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { controlSocketPathIn } from "./control-socket-path.js";
26
+ import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
27
+ import type { EntwurfV2SurfaceRendered, SurfaceEntwurfV2Params } from "./entwurf-v2-surface.ts";
28
+ import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.ts";
29
+ import type { RemoteClaim, RemotePairRejectReason } from "./remote-pair-receive.ts";
30
+ import { inspectControlSocketPath } from "./socket-discovery.ts";
31
+
32
+ /** The SENDING host's declaration of its one remote pair. */
33
+ export interface RemoteRouteDeclaration {
34
+ /** The target's garden id on the other host. */
35
+ remoteGardenId: string;
36
+ /** SSH alias for the other host (route label; never an identity). */
37
+ ssh: string;
38
+ /** The garden id on THIS host that may send to it. */
39
+ localGardenId: string;
40
+ /** Absolute path of the other host's pair declaration, passed to its receive entry. */
41
+ remotePairFile: string;
42
+ }
43
+
44
+ export interface RemoteTransportResult {
45
+ exitCode: number | null;
46
+ signal: string | null;
47
+ timedOut: boolean;
48
+ stdout: string;
49
+ stderr: string;
50
+ }
51
+
52
+ export interface RemoteTransport {
53
+ run(argv: readonly string[], stdin: string, timeoutMs: number): Promise<RemoteTransportResult>;
54
+ }
55
+
56
+ export type RemoteSendOutcome =
57
+ | { kind: "remote-accepted"; text: string }
58
+ | { kind: "remote-pair-refused"; reason: RemotePairRejectReason; detail: string }
59
+ | { kind: "remote-reported-error"; text: string }
60
+ | { kind: "remote-invalid"; detail: string }
61
+ | { kind: "remote-indeterminate"; why: string; exitCode: number | null; stderrTail: string };
62
+
63
+ export class RemoteRouteDeclarationError extends Error {}
64
+
65
+ const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
66
+ const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
67
+ const CONNECT_TIMEOUT_SECONDS = 10;
68
+ export const REMOTE_SEND_TIMEOUT_MS = 30_000;
69
+ const PAIR_REJECT_REASONS: readonly string[] = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
70
+
71
+ export function parseRemoteRouteDeclaration(text: string): RemoteRouteDeclaration {
72
+ const what = "remote route declaration";
73
+ let raw: unknown;
74
+ try {
75
+ raw = JSON.parse(text);
76
+ } catch (err) {
77
+ throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
78
+ }
79
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
80
+ throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
81
+ }
82
+ const obj = raw as Record<string, unknown>;
83
+ const want = ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"];
84
+ const got = Object.keys(obj).sort();
85
+ if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
86
+ throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
87
+ }
88
+ const { ssh, remotePairFile } = obj;
89
+ if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
90
+ throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
91
+ }
92
+ if (
93
+ typeof remotePairFile !== "string" ||
94
+ !REMOTE_PATH.test(remotePairFile) ||
95
+ remotePairFile.split("/").includes("..")
96
+ ) {
97
+ throw new RemoteRouteDeclarationError(
98
+ `${what}: "remotePairFile" must be an absolute path matching ${REMOTE_PATH} with no ".." segment`,
99
+ );
100
+ }
101
+ return {
102
+ remoteGardenId: requireGardenId(obj.remoteGardenId),
103
+ ssh,
104
+ localGardenId: requireGardenId(obj.localGardenId),
105
+ remotePairFile,
106
+ };
107
+ }
108
+
109
+ /** The one argv the transport runs. Every word is inside the no-quoting charset above. */
110
+ export function remoteReceiveArgv(route: RemoteRouteDeclaration): string[] {
111
+ return [
112
+ "ssh",
113
+ "-o",
114
+ "BatchMode=yes",
115
+ "-o",
116
+ `ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
117
+ route.ssh,
118
+ "entwurf",
119
+ "remote-pair-receive",
120
+ "--pair",
121
+ route.remotePairFile,
122
+ ];
123
+ }
124
+
125
+ function tail(s: string): string {
126
+ return s.length > 400 ? s.slice(-400) : s;
127
+ }
128
+
129
+ /**
130
+ * Decide only on agreement. 0 + dispatched/!isError = accepted; 3 + rejected(pair reason) = pair
131
+ * refused; 4 + dispatched/isError = the other host reported an error while processing (its own text
132
+ * says what is known, e.g. `retry-safe: false` after a possible write; this line infers neither
133
+ * delivery nor non-delivery); 2 + invalid = it refused our
134
+ * input. Any other exit, a signal, a timeout, no line, more than one line, non-JSON, or a JSON
135
+ * kind/field that disagrees with the exit is indeterminate.
136
+ */
137
+ export function classifyRemoteReceipt(r: RemoteTransportResult): RemoteSendOutcome {
138
+ const indeterminate = (why: string): RemoteSendOutcome => ({
139
+ kind: "remote-indeterminate",
140
+ why,
141
+ exitCode: r.exitCode,
142
+ stderrTail: tail(r.stderr),
143
+ });
144
+ if (r.timedOut) return indeterminate("timed out");
145
+ if (r.signal !== null) return indeterminate(`ended by signal ${r.signal}`);
146
+ if (r.exitCode === null) {
147
+ return indeterminate(
148
+ r.stderr.startsWith("spawn failed")
149
+ ? "the transport did not start (spawn failed)"
150
+ : "did not exit (no exit code)",
151
+ );
152
+ }
153
+ if (![0, 2, 3, 4].includes(r.exitCode)) return indeterminate(`exit ${r.exitCode}`);
154
+ const nl = r.stdout.indexOf("\n");
155
+ if (nl === -1 || nl !== r.stdout.length - 1) return indeterminate("stdout is not exactly one line");
156
+ let j: unknown;
157
+ try {
158
+ j = JSON.parse(r.stdout);
159
+ } catch {
160
+ return indeterminate("stdout is not JSON");
161
+ }
162
+ if (j === null || typeof j !== "object" || Array.isArray(j)) return indeterminate("stdout is not a JSON object");
163
+ const o = j as Record<string, unknown>;
164
+ const keys = Object.keys(o).sort().join(",");
165
+ if (r.exitCode === 0 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === false) {
166
+ if (typeof o.text === "string") return { kind: "remote-accepted", text: o.text };
167
+ }
168
+ if (r.exitCode === 4 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === true) {
169
+ if (typeof o.text === "string") return { kind: "remote-reported-error", text: o.text };
170
+ }
171
+ if (r.exitCode === 3 && keys === "detail,kind,reason" && o.kind === "rejected") {
172
+ if (typeof o.reason === "string" && PAIR_REJECT_REASONS.includes(o.reason) && typeof o.detail === "string") {
173
+ return { kind: "remote-pair-refused", reason: o.reason as RemotePairRejectReason, detail: o.detail };
174
+ }
175
+ }
176
+ if (r.exitCode === 2 && keys === "detail,kind" && o.kind === "invalid" && typeof o.detail === "string") {
177
+ return { kind: "remote-invalid", detail: o.detail };
178
+ }
179
+ return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
180
+ }
181
+
182
+ function render(route: RemoteRouteDeclaration, o: RemoteSendOutcome): EntwurfV2SurfaceRendered {
183
+ const via = `via ${route.ssh}`;
184
+ switch (o.kind) {
185
+ case "remote-accepted":
186
+ return {
187
+ isError: false,
188
+ 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}`,
189
+ };
190
+ case "remote-reported-error":
191
+ // The other host's dispatch returned isError. That can follow a write (an enqueue whose
192
+ // stamp or signal then threw is `retry-safe: false`), so this header claims neither
193
+ // non-delivery nor safety to resend: the other host's own text below is the fact.
194
+ return {
195
+ isError: true,
196
+ 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}`,
197
+ };
198
+ case "remote-pair-refused":
199
+ return {
200
+ isError: true,
201
+ text: `entwurf_v2 remote ${via} → refused by the other host's pair: ${o.reason} (${o.detail})`,
202
+ };
203
+ case "remote-invalid":
204
+ return { isError: true, text: `entwurf_v2 remote ${via} → the other host refused the input: ${o.detail}` };
205
+ case "remote-indeterminate":
206
+ return {
207
+ isError: true,
208
+ text:
209
+ `entwurf_v2 remote ${via} → indeterminate: ${o.why}. The other host may or may not have enqueued; ` +
210
+ `not retried.${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
211
+ };
212
+ }
213
+ }
214
+
215
+ function refuse(text: string): EntwurfV2SurfaceRendered {
216
+ return { isError: true, text: `entwurf_v2 rejected: ${text}` };
217
+ }
218
+
219
+ export interface RemoteRouteOptions {
220
+ senderProvider: () => SenderEnvelope | undefined;
221
+ /** The declaration, already read; null = no remote pair on this host. */
222
+ route: RemoteRouteDeclaration | null;
223
+ /** Any local trace of this gid: a record entry in any state, or anything at its socket path. */
224
+ localTrace: (gardenId: string) => Promise<boolean>;
225
+ transport: RemoteTransport;
226
+ /** The existing local surface, run unchanged when the route does not apply. */
227
+ runLocal: (
228
+ params: SurfaceEntwurfV2Params,
229
+ opts: { senderProvider: () => SenderEnvelope | undefined },
230
+ ) => Promise<EntwurfV2SurfaceRendered>;
231
+ timeoutMs?: number;
232
+ }
233
+
234
+ export async function runEntwurfV2WithRemoteRoute(
235
+ params: SurfaceEntwurfV2Params,
236
+ opts: RemoteRouteOptions,
237
+ ): Promise<EntwurfV2SurfaceRendered> {
238
+ const route = opts.route;
239
+ if (route === null || params.target !== route.remoteGardenId) {
240
+ return opts.runLocal(params, { senderProvider: opts.senderProvider });
241
+ }
242
+ if (await opts.localTrace(route.remoteGardenId)) {
243
+ return refuse(
244
+ `remote-route-local-conflict: ${route.remoteGardenId} is declared remote ${`via ${route.ssh}`} but this host also has a local trace of it; not choosing`,
245
+ );
246
+ }
247
+ const sender = opts.senderProvider();
248
+ if (!sender || sender.origin === "external-mcp" || sender.sessionId !== route.localGardenId) {
249
+ return refuse(
250
+ `remote-sender-not-declared: only ${route.localGardenId} may send over this route (sender: ${sender ? sender.sessionId : "none"})`,
251
+ );
252
+ }
253
+ const claim: RemoteClaim = {
254
+ from: sender.sessionId,
255
+ fromAgentId: sender.agentId,
256
+ fromCwd: sender.cwd,
257
+ target: route.remoteGardenId,
258
+ message: params.message,
259
+ wantsReply: params.wants_reply ?? false,
260
+ };
261
+ const result = await opts.transport.run(
262
+ remoteReceiveArgv(route),
263
+ JSON.stringify(claim),
264
+ opts.timeoutMs ?? REMOTE_SEND_TIMEOUT_MS,
265
+ );
266
+ return render(route, classifyRemoteReceipt(result));
267
+ }
268
+
269
+ /**
270
+ * The production local-trace fact: the store's own record-entry inspection (absent vs anything
271
+ * else — readable, unreadable, symlinked or drifted all count) and the control-socket path
272
+ * inspection, in EVERY socket dir the caller names. The bridge's `ENTWURF_DIR` can differ from the
273
+ * decider's canonical dir, so both are passed and a trace in either one means this host already
274
+ * answers to the gid.
275
+ */
276
+ export function makeLocalTraceFact(dirs: { sessionsDir: string; controlSocketDirs: readonly string[] }) {
277
+ const socketDirs = [...new Set(dirs.controlSocketDirs.map((d) => path.resolve(d)))];
278
+ return async (gardenId: string): Promise<boolean> => {
279
+ if (metaRecordExistsByGardenId(gardenId, dirs.sessionsDir)) return true;
280
+ for (const dir of socketDirs) {
281
+ const inspection = await inspectControlSocketPath(controlSocketPathIn(dir, gardenId));
282
+ if (inspection.kind !== "absent") return true;
283
+ }
284
+ return false;
285
+ };
286
+ }
287
+
288
+ /** The operator's opt-in file name. One per host; it declares that host's one remote pair. */
289
+ export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
290
+
291
+ /**
292
+ * Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
293
+ * `ENTWURF_META_SESSIONS_DIR` override relocates the store (then the file follows the store it
294
+ * pairs). Read-only reuse of the existing root resolution; nothing here writes.
295
+ */
296
+ export function remotePairOptInPath(): string {
297
+ return path.join(path.dirname(defaultMetaSessionsDir()), REMOTE_PAIR_OPT_IN_FILE);
298
+ }
299
+
300
+ /**
301
+ * Read the opt-in file once, at the moment of use (no cache). Absent is the ONLY "off". A
302
+ * symlink, a non-regular entry, an unreadable file or an invalid declaration throws: the operator
303
+ * opted in, so a broken opt-in is refused rather than read as off.
304
+ *
305
+ * The bridge this declares is the operator's to build and secure (for example a private network
306
+ * such as Tailscale, plus SSH authentication and permissions). Entwurf only walks the declared
307
+ * route: it installs, configures, discovers and verifies none of it, and never retries.
308
+ */
309
+ export function readRemotePairOptIn(file: string): RemoteRouteDeclaration | null {
310
+ let st: fs.Stats;
311
+ try {
312
+ st = fs.lstatSync(file);
313
+ } catch (err) {
314
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
315
+ throw err;
316
+ }
317
+ if (!st.isFile()) {
318
+ throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
319
+ }
320
+ try {
321
+ return parseRemoteRouteDeclaration(fs.readFileSync(file, "utf8"));
322
+ } catch (err) {
323
+ throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
324
+ }
325
+ }
@@ -0,0 +1,81 @@
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
+
12
+ import { spawn } from "node:child_process";
13
+ import type { RemoteTransport, RemoteTransportResult } from "./remote-pair-send.ts";
14
+
15
+ const CAPTURE_CAP = 64 * 1024;
16
+ const KILL_GRACE_MS = 2000;
17
+ const FINAL_GRACE_MS = 2000;
18
+
19
+ function capped(chunks: Buffer[], size: { n: number }, chunk: Buffer): void {
20
+ if (size.n >= CAPTURE_CAP) return;
21
+ const room = CAPTURE_CAP - size.n;
22
+ const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
23
+ chunks.push(piece);
24
+ size.n += piece.length;
25
+ }
26
+
27
+ export const processTransport: RemoteTransport = {
28
+ run(argv, stdin, timeoutMs): Promise<RemoteTransportResult> {
29
+ return new Promise((resolve) => {
30
+ const out: Buffer[] = [];
31
+ const err: Buffer[] = [];
32
+ const outN = { n: 0 };
33
+ const errN = { n: 0 };
34
+ let timedOut = false;
35
+ let spawnError: string | null = null;
36
+ let settled = false;
37
+ const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
38
+ let killTimer: NodeJS.Timeout | undefined;
39
+ let finalTimer: NodeJS.Timeout | undefined;
40
+ const settle = (code: number | null, signal: NodeJS.Signals | null): void => {
41
+ if (settled) return;
42
+ settled = true;
43
+ clearTimeout(bound);
44
+ if (killTimer) clearTimeout(killTimer);
45
+ if (finalTimer) clearTimeout(finalTimer);
46
+ const stderr = Buffer.concat(err).toString("utf8");
47
+ resolve({
48
+ exitCode: spawnError ? null : code,
49
+ signal: spawnError ? null : signal,
50
+ timedOut,
51
+ stdout: Buffer.concat(out).toString("utf8"),
52
+ stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
53
+ });
54
+ };
55
+ const bound = setTimeout(() => {
56
+ timedOut = true;
57
+ child.kill("SIGTERM");
58
+ killTimer = setTimeout(() => {
59
+ child.kill("SIGKILL");
60
+ // Something else may still hold our stdout/stderr pipes (an inherited descendant,
61
+ // an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
62
+ // ends and settle as a timeout, which the receipt reads as indeterminate.
63
+ finalTimer = setTimeout(() => {
64
+ child.stdout.destroy();
65
+ child.stderr.destroy();
66
+ settle(child.exitCode, child.signalCode);
67
+ }, FINAL_GRACE_MS);
68
+ }, KILL_GRACE_MS);
69
+ }, timeoutMs);
70
+ child.stdout.on("data", (c: Buffer) => capped(out, outN, c));
71
+ child.stderr.on("data", (c: Buffer) => capped(err, errN, c));
72
+ child.on("error", (e) => {
73
+ spawnError = e.message;
74
+ });
75
+ // A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
76
+ child.stdin.on("error", () => {});
77
+ child.on("close", (code, signal) => settle(code, signal));
78
+ child.stdin.end(stdin);
79
+ });
80
+ },
81
+ };
@@ -57,6 +57,7 @@ import {
57
57
  controlSocketPathIn,
58
58
  defaultControlSocketDir,
59
59
  gardenIdFromSocketFilename,
60
+ WIN32_PIPE_PREFIX,
60
61
  } from "./control-socket-path.js";
61
62
  import type { SocketProbe } from "./entwurf-facts.ts";
62
63
  import { SESSION_ID_RE } from "./session-id.js";
@@ -117,7 +118,9 @@ export type TargetSocketInspection =
117
118
  * `record-less-socket` reject (a diagnostic state) — no probe, no acceptance.
118
119
  */
119
120
  export function isRecordLessSocketCandidate(inspection: TargetSocketInspection): boolean {
120
- return inspection.kind === "socket-file";
121
+ // A Windows pipe name (#78) is never lstat-ed (see inspectControlSocketPath), so its
122
+ // `socket-file` carries no presence evidence and cannot confirm a record-less socket.
123
+ return inspection.kind === "socket-file" && !inspection.socketPath.startsWith(WIN32_PIPE_PREFIX);
121
124
  }
122
125
 
123
126
  export interface LstatLike {
@@ -139,6 +142,11 @@ export async function inspectControlSocketPath(
139
142
  socketPath: string,
140
143
  lstatFn: (p: string) => Promise<LstatLike> = (p) => fs.lstat(p),
141
144
  ): Promise<TargetSocketInspection> {
145
+ // Windows (#78): the address is a named pipe. The pipe filesystem has no symlinks to
146
+ // catch, and opening the name to stat it would CONNECT to it and consume an instance.
147
+ // So the name is never stat-ed: it is safe to probe because the probe itself refuses a
148
+ // pipe whose owner is not this token's expected owner before any byte.
149
+ if (socketPath.startsWith(WIN32_PIPE_PREFIX)) return { kind: "socket-file", socketPath };
142
150
  let st: LstatLike;
143
151
  try {
144
152
  st = await lstatFn(socketPath);
@@ -243,9 +251,12 @@ export async function scanSocketProbes(
243
251
  deps: Partial<SocketScanDeps> = {},
244
252
  ): Promise<SocketScanResult> {
245
253
  const dir = deps.dir ?? CONTROL_SOCKET_DIR;
254
+ // Windows (#78) has no socket files to list and nothing scans the pipe namespace: the
255
+ // candidates are the record-backed citizens alone.
246
256
  const readdir =
247
257
  deps.readdir ??
248
258
  (async (d: string): Promise<SocketDirEntry[]> => {
259
+ if (process.platform === "win32") return [];
249
260
  const dirents = await fs.readdir(d, { withFileTypes: true });
250
261
  return dirents.map((e) => ({ name: e.name, isSymbolicLink: e.isSymbolicLink() }));
251
262
  });
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import * as net from "node:net";
32
+ import { win32Native } from "./win32-native.js";
32
33
 
33
34
  export type SocketLiveness = "alive" | "dead" | "indeterminate";
34
35
 
@@ -66,6 +67,7 @@ export async function probeSocketLiveness(
66
67
  opts: { timeoutMs?: number } = {},
67
68
  ): Promise<SocketLiveness> {
68
69
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
70
+ if (process.platform === "win32") return await probePipeLiveness(socketPath, timeoutMs);
69
71
  return await new Promise<SocketLiveness>((resolve) => {
70
72
  const socket = net.createConnection(socketPath);
71
73
  const timer = setTimeout(() => {
@@ -86,3 +88,21 @@ export async function probeSocketLiveness(
86
88
  });
87
89
  });
88
90
  }
91
+
92
+ /**
93
+ * The Windows probe (#78): the control address is a named pipe, and a connect alone would
94
+ * talk to whoever holds the name. The addon connects within one deadline and refuses a pipe
95
+ * whose owner is not this token's expected owner (`EPERM`) before any byte. Only `ENOENT`
96
+ * (no instance) is dead; a refused owner, a busy pipe past the deadline and every Win32
97
+ * failure stay indeterminate. A missing or drifted addon is not a liveness fact: that
98
+ * refusal throws.
99
+ */
100
+ async function probePipeLiveness(pipeName: string, timeoutMs: number): Promise<SocketLiveness> {
101
+ const native = win32Native();
102
+ try {
103
+ await native.pipeProbe(pipeName, Math.max(0, Math.floor(timeoutMs)));
104
+ return "alive";
105
+ } catch (err) {
106
+ return classifyConnectError((err as { code?: string }).code);
107
+ }
108
+ }
@@ -0,0 +1,198 @@
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
+
25
+ import { createHash } from "node:crypto";
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ export const WIN32_UNSUPPORTED_CODE = "ENTWURF_WIN32_UNSUPPORTED";
31
+ export const WIN32_DRIFT_CODE = "ENTWURF_WIN32_NATIVE_DRIFT";
32
+
33
+ const PACKAGE_NAME = "@junghanacs/entwurf";
34
+ const NATIVE_DIR = path.join("native", "win32-x64");
35
+ const ADDON_FILE = "entwurf_win.node";
36
+ const SHA256 = /^[0-9a-f]{64}$/;
37
+ // The addon is built against exactly this N-API version (native/win32/binding.gyp, build.mjs). A
38
+ // manifest that declares any other value describes some other build: drift, not a floor to honour.
39
+ const ADDON_NAPI_VERSION = 8;
40
+ // One addon object per process, even if two module instances of this file exist (a
41
+ // transpiling loader and Node's own loader can each evaluate it once).
42
+ const SLOT = Symbol.for("entwurf.win32-native.addon");
43
+ // DOS device names open a device, not a file, at any directory. The addon's GetFileType
44
+ // check refuses a non-disk handle anyway; this keeps the open from ever being attempted.
45
+ const DOS_DEVICE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i;
46
+
47
+ /**
48
+ * @param {string} code
49
+ * @param {string} message
50
+ * @param {unknown} [cause]
51
+ * @returns {Error & { code: string }}
52
+ */
53
+ function refusal(code, message, cause) {
54
+ const err = /** @type {Error & { code: string }} */ (new Error(message, cause === undefined ? undefined : { cause }));
55
+ err.code = code;
56
+ return err;
57
+ }
58
+
59
+ /**
60
+ * The package root, found from where THIS module lives: the source depth
61
+ * (`<root>/pi-extensions/lib/`) first, then the compiled depth
62
+ * (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`). The root is the directory whose
63
+ * `package.json` names this package; `null` when neither depth does.
64
+ *
65
+ * @param {string} [from]
66
+ * @returns {string | null}
67
+ */
68
+ export function win32NativePackageRoot(from = path.dirname(fileURLToPath(import.meta.url))) {
69
+ for (const up of [
70
+ ["..", ".."],
71
+ ["..", "..", "..", "..", ".."],
72
+ ]) {
73
+ const root = path.resolve(from, ...up);
74
+ let name;
75
+ try {
76
+ name = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))?.name;
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (name === PACKAGE_NAME) return root;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Verify `<root>/native/win32-x64/` and return the addon path to load. Pure apart from
87
+ * reading the two files, so the refusals are provable on any host.
88
+ *
89
+ * @param {string} root
90
+ * @param {{ napi?: string | undefined }} [versions]
91
+ * @returns {{ addonPath: string, sha256: string, napiVersion: number }}
92
+ */
93
+ export function verifyWin32NativeAt(root, versions = process.versions) {
94
+ const dir = path.join(root, NATIVE_DIR);
95
+ const manifestPath = path.join(dir, "manifest.json");
96
+ let raw;
97
+ try {
98
+ raw = fs.readFileSync(manifestPath, "utf8");
99
+ } catch (err) {
100
+ if (/** @type {{ code?: unknown }} */ (err).code === "ENOENT") {
101
+ throw refusal(
102
+ WIN32_UNSUPPORTED_CODE,
103
+ `this entwurf install carries no Windows addon (${manifestPath} is absent): only the Windows preview ` +
104
+ "artifact runs on Windows; an ordinary pack does not.",
105
+ );
106
+ }
107
+ throw refusal(WIN32_DRIFT_CODE, `cannot read ${manifestPath}`, err);
108
+ }
109
+ let manifest;
110
+ try {
111
+ manifest = JSON.parse(raw);
112
+ } catch (err) {
113
+ throw refusal(WIN32_DRIFT_CODE, `${manifestPath} is not JSON`, err);
114
+ }
115
+ if (
116
+ manifest?.schema !== 1 ||
117
+ manifest.platform !== "win32" ||
118
+ manifest.arch !== "x64" ||
119
+ manifest.file !== ADDON_FILE ||
120
+ typeof manifest.sha256 !== "string" ||
121
+ !SHA256.test(manifest.sha256) ||
122
+ manifest.napiVersion !== ADDON_NAPI_VERSION
123
+ ) {
124
+ throw refusal(WIN32_DRIFT_CODE, `${manifestPath} does not describe one win32-x64 ${ADDON_FILE}`);
125
+ }
126
+ const napi = Number(versions.napi);
127
+ if (!(napi >= ADDON_NAPI_VERSION)) {
128
+ throw refusal(
129
+ WIN32_UNSUPPORTED_CODE,
130
+ `the Windows addon needs N-API ${ADDON_NAPI_VERSION}; this Node offers ${versions.napi ?? "none"}`,
131
+ );
132
+ }
133
+ const addonPath = path.join(dir, ADDON_FILE);
134
+ let bytes;
135
+ try {
136
+ bytes = fs.readFileSync(addonPath);
137
+ } catch (err) {
138
+ throw refusal(WIN32_DRIFT_CODE, `the manifest names ${addonPath}, which cannot be read`, err);
139
+ }
140
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
141
+ if (sha256 !== manifest.sha256) {
142
+ throw refusal(
143
+ WIN32_DRIFT_CODE,
144
+ `${addonPath} is not the addon its manifest names (sha256 ${sha256} != ${manifest.sha256})`,
145
+ );
146
+ }
147
+ return { addonPath, sha256, napiVersion: ADDON_NAPI_VERSION };
148
+ }
149
+
150
+ /**
151
+ * @typedef {object} Win32NativeAddon
152
+ * @property {(file: string) => Buffer} readNoFollow
153
+ * @property {(name: string, line: string, timeoutMs: number) => Promise<{ responseLine: string, serverPid: number }>} pipeRpc
154
+ * @property {(name: string, timeoutMs: number) => Promise<{ serverPid: number }>} pipeProbe
155
+ * @property {() => { live: number, cancelledIncomplete: number }} ioStats
156
+ */
157
+
158
+ /** @returns {Win32NativeAddon} */
159
+ export function win32Native() {
160
+ const held = /** @type {Record<symbol, Win32NativeAddon | undefined>} */ (/** @type {unknown} */ (process))[SLOT];
161
+ if (held) return held;
162
+ if (process.platform !== "win32" || process.arch !== "x64") {
163
+ throw refusal(
164
+ WIN32_UNSUPPORTED_CODE,
165
+ `the Windows native surface exists only on win32-x64, not ${process.platform}-${process.arch}`,
166
+ );
167
+ }
168
+ const root = win32NativePackageRoot();
169
+ if (root === null) {
170
+ throw refusal(WIN32_UNSUPPORTED_CODE, `cannot find the ${PACKAGE_NAME} package root beside this module`);
171
+ }
172
+ const { addonPath } = verifyWin32NativeAt(root);
173
+ const module = { exports: {} };
174
+ try {
175
+ process.dlopen(module, addonPath);
176
+ } catch (err) {
177
+ throw refusal(WIN32_UNSUPPORTED_CODE, `this host cannot load the verified Windows addon ${addonPath}`, err);
178
+ }
179
+ const addon = /** @type {Win32NativeAddon} */ (module.exports);
180
+ /** @type {Record<symbol, Win32NativeAddon | undefined>} */ (/** @type {unknown} */ (process))[SLOT] = addon;
181
+ return addon;
182
+ }
183
+
184
+ /**
185
+ * Read a record's bytes on Windows, refusing a final-component reparse point (`ELOOP`) and
186
+ * a non-regular entry (`ENTWURF_ENOTREG`) on the very handle the read uses. Over 16 MiB is
187
+ * `EFBIG`; other Win32 failures are `EWIN32_<n>`.
188
+ *
189
+ * @param {string} file
190
+ * @returns {Buffer}
191
+ */
192
+ export function win32ReadNoFollow(file) {
193
+ const stem = path.win32.basename(file).split(".")[0].replace(/[ ]+$/, "");
194
+ if (DOS_DEVICE.test(stem)) {
195
+ throw refusal("ENTWURF_ENOTREG", `${path.win32.basename(file)} names a DOS device, never a record`);
196
+ }
197
+ return win32Native().readNoFollow(file);
198
+ }