@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.
- package/BASELINE.md +11 -4
- package/CHANGELOG.md +323 -0
- package/DELIVERY.md +34 -0
- package/LICENSES/node-gyp.txt +24 -0
- package/README.md +20 -6
- package/VERIFY.md +23 -4
- package/docs/setup-clean-host.md +43 -2
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-socket-path.js +56 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +25 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +14 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +14 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/win32-native.js +182 -0
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +38 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -0
- package/native/win32-x64/entwurf_win.node +0 -0
- package/native/win32-x64/manifest.json +45 -0
- package/package.json +5 -3
- package/pi-extensions/entwurf-control.ts +6 -1
- package/pi-extensions/lib/control-socket-path.js +56 -1
- package/pi-extensions/lib/entwurf-control-rpc.ts +32 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- package/pi-extensions/lib/meta-session.ts +14 -0
- package/pi-extensions/lib/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -0
- package/pi-extensions/lib/socket-discovery.ts +12 -1
- package/pi-extensions/lib/socket-probe.ts +20 -0
- package/pi-extensions/lib/win32-native.js +198 -0
- package/run.sh +149 -1
- package/scripts/check-entwurf-self-address.ts +15 -4
- package/scripts/check-gate-qualification.ts +8 -1
- package/scripts/check-remote-pair-installed.ts +242 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -0
- package/scripts/mutants/control-send-receipt.json +2 -2
- package/scripts/mutants/pack-install.json +28 -2
- package/scripts/mutants/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -0
- package/scripts/mutants/win32-preview.json +215 -0
- package/scripts/remote-pair-receive.ts +73 -0
package/docs/setup-clean-host.md
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
:
|
|
66
|
-
? `${sender.sessionId} (
|
|
67
|
-
:
|
|
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} @ ${
|
|
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
|
+
}
|