@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
|
@@ -108,6 +108,15 @@ import {
|
|
|
108
108
|
} from "../../../pi-extensions/lib/meta-session.ts";
|
|
109
109
|
import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.ts";
|
|
110
110
|
import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.ts";
|
|
111
|
+
import {
|
|
112
|
+
makeLocalTraceFact,
|
|
113
|
+
readRemotePairOptIn,
|
|
114
|
+
remotePairOptInPath,
|
|
115
|
+
runEntwurfV2WithRemoteRoute,
|
|
116
|
+
} from "../../../pi-extensions/lib/remote-pair-send.ts";
|
|
117
|
+
import { processTransport } from "../../../pi-extensions/lib/remote-pair-transport.ts";
|
|
118
|
+
import { CONTROL_SOCKET_DIR } from "../../../pi-extensions/lib/socket-discovery.ts";
|
|
119
|
+
import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.ts";
|
|
111
120
|
|
|
112
121
|
const HOME = os.homedir();
|
|
113
122
|
|
|
@@ -137,6 +146,14 @@ try {
|
|
|
137
146
|
// ENTWURF_DIR override the pi side does not. The path GRAMMAR is the shared leaf.
|
|
138
147
|
const ENTWURF_DIR = process.env.ENTWURF_DIR ?? defaultControlSocketDir(HOME);
|
|
139
148
|
|
|
149
|
+
/** Is this session's own control address up? A socket file's presence on Linux/macOS. On
|
|
150
|
+
* Windows (#78) the address is a named pipe, which has no file to look at and must not be
|
|
151
|
+
* opened by a stat (that connects): the owner-validated probe answers instead. */
|
|
152
|
+
async function controlAddressPresent(socketPath: string): Promise<boolean> {
|
|
153
|
+
if (process.platform === "win32") return (await probeSocketLiveness(socketPath)) === "alive";
|
|
154
|
+
return existsSync(socketPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
// ============================================================================
|
|
141
158
|
// Live control-socket discovery for entwurf_peers lives in the TS fact-provider
|
|
142
159
|
// (pi-extensions/lib/entwurf-fact-provider.ts → listEntwurfFacts), which the
|
|
@@ -219,7 +236,7 @@ class EntwurfSenderIdentityError extends Error {
|
|
|
219
236
|
}
|
|
220
237
|
}
|
|
221
238
|
|
|
222
|
-
function buildStrictPiSenderEnvelope(): SenderEnvelope {
|
|
239
|
+
async function buildStrictPiSenderEnvelope(): Promise<SenderEnvelope> {
|
|
223
240
|
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
224
241
|
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
225
242
|
const cwd = process.cwd();
|
|
@@ -239,7 +256,7 @@ function buildStrictPiSenderEnvelope(): SenderEnvelope {
|
|
|
239
256
|
const socketPath = controlSocketPathIn(ENTWURF_DIR, sessionId as string);
|
|
240
257
|
const self = computeSelfAddressability({
|
|
241
258
|
origin: "pi-session",
|
|
242
|
-
socketAlive:
|
|
259
|
+
socketAlive: await controlAddressPresent(socketPath),
|
|
243
260
|
socketPathComputable: true,
|
|
244
261
|
});
|
|
245
262
|
return {
|
|
@@ -338,7 +355,7 @@ async function resolveAuthoritativeSender(
|
|
|
338
355
|
): Promise<AuthoritativeSelf | null> {
|
|
339
356
|
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
340
357
|
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
341
|
-
const pi = sessionId && agentId && cwd ? buildStrictPiSenderEnvelope() : null;
|
|
358
|
+
const pi = sessionId && agentId && cwd ? await buildStrictPiSenderEnvelope() : null;
|
|
342
359
|
const marker = resolveTrustedMetaSenderIdentity({
|
|
343
360
|
markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
|
|
344
361
|
});
|
|
@@ -511,12 +528,24 @@ server.tool(
|
|
|
511
528
|
// Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
|
|
512
529
|
// + the mailbox body sender share one envelope. No replyability gate (see above).
|
|
513
530
|
const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
|
|
514
|
-
|
|
531
|
+
// #107: the operator's remote-pair opt-in is read per send. Absent = off and the local
|
|
532
|
+
// surface below runs exactly as before; a declared target goes over that one route.
|
|
533
|
+
const rendered = await runEntwurfV2WithRemoteRoute(
|
|
515
534
|
{ target, intent, message, mode, wants_reply },
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
535
|
+
{
|
|
536
|
+
senderProvider: () => sender,
|
|
537
|
+
route: readRemotePairOptIn(remotePairOptInPath()),
|
|
538
|
+
// The decider's canonical socket dir AND this bridge's ENTWURF_DIR: a trace in either is local.
|
|
539
|
+
localTrace: makeLocalTraceFact({
|
|
540
|
+
sessionsDir: defaultMetaSessionsDir(),
|
|
541
|
+
controlSocketDirs: [CONTROL_SOCKET_DIR, ENTWURF_DIR],
|
|
542
|
+
}),
|
|
543
|
+
transport: processTransport,
|
|
544
|
+
// No trust-preflight inputs are passed, and none exist to pass: the preflight on this
|
|
545
|
+
// path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
|
|
546
|
+
// the whole options surface now.
|
|
547
|
+
runLocal: (params, opts) => runAndRenderEntwurfV2FromSurface(params, opts),
|
|
548
|
+
},
|
|
520
549
|
);
|
|
521
550
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
522
551
|
} catch (err) {
|
|
@@ -559,7 +588,7 @@ server.tool(
|
|
|
559
588
|
// live socket). The old code synthesized the path and printed it as if
|
|
560
589
|
// it existed — a lie when the session has no --entwurf-control (SE-1).
|
|
561
590
|
const socketPath = controlSocketPathIn(ENTWURF_DIR, sender.sessionId);
|
|
562
|
-
const socketState =
|
|
591
|
+
const socketState = (await controlAddressPresent(socketPath)) ? "alive" : "expected";
|
|
563
592
|
extra.socketPath = socketPath;
|
|
564
593
|
extra.socketState = socketState;
|
|
565
594
|
lines.push(
|
|
@@ -141,6 +141,12 @@
|
|
|
141
141
|
// only repo import is meta-session.ts, ALREADY emitted for the store-doctor, so this adds
|
|
142
142
|
// ONE leaf JS and ZERO new deps. It is an omp-loaded extension module, never in
|
|
143
143
|
// index.ts's boot closure, so check-entwurf-bridge-pi-free is unaffected.
|
|
144
|
+
// WHY remote-pair-receive rides THIS build (#107): the sending host's ssh runs the receiving
|
|
145
|
+
// host's `entwurf remote-pair-receive`, and on an installed host run.sh lives under
|
|
146
|
+
// node_modules, where run_ts needs a compiled twin. Its repo imports (remote-pair-receive,
|
|
147
|
+
// remote-pair-send, entwurf-v2-surface, meta-session) are ALREADY emitted for the bridge's own
|
|
148
|
+
// entwurf_v2 handler, so this adds ONE leaf JS and ZERO new deps. It is never in index.ts's boot
|
|
149
|
+
// closure.
|
|
144
150
|
"include": [
|
|
145
151
|
"./src/index.ts",
|
|
146
152
|
"../../protocol.js",
|
|
@@ -156,6 +162,7 @@
|
|
|
156
162
|
"../../scripts/omp-receive-facts.ts",
|
|
157
163
|
"../../scripts/codex-socket-path.ts",
|
|
158
164
|
"../../scripts/codex-socket-classify.ts",
|
|
165
|
+
"../../scripts/remote-pair-receive.ts",
|
|
159
166
|
"../../pi-extensions/meta-bridge-hook.ts",
|
|
160
167
|
"../../pi-extensions/meta-bridge-hook-copilot.ts",
|
|
161
168
|
"../../pi-extensions/meta-bridge-hook-codex.ts",
|
|
Binary file
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"platform": "win32",
|
|
4
|
+
"arch": "x64",
|
|
5
|
+
"file": "entwurf_win.node",
|
|
6
|
+
"sha256": "9837893521ec1d033913ca6cd1b705cb6e942e8d5c288948a1ed0ca0e28655e5",
|
|
7
|
+
"napiVersion": 8,
|
|
8
|
+
"builtWith": {
|
|
9
|
+
"node": "24.18.1",
|
|
10
|
+
"nodeGyp": "13.1.0",
|
|
11
|
+
"platformToolset": "v145",
|
|
12
|
+
"windowsTargetPlatformVersion": "10.0.26100.0",
|
|
13
|
+
"visualStudio": {
|
|
14
|
+
"displayName": "Visual Studio Enterprise 2026",
|
|
15
|
+
"installationVersion": "18.10.12224.181"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"source": {
|
|
19
|
+
"entwurf_win.c": "aa8a2994833d6dbbbf56c78687d42df52f645f3f35073943d897f58169219c31",
|
|
20
|
+
"binding.gyp": "be95dcfb152a91377a011aedbadd7943965ede251612394eacb405b20689f18e",
|
|
21
|
+
"package.json": "6ba65c909857f18a5d8e16b63fa55201b1d17261cc4afb1f2eea10514b2cac6a",
|
|
22
|
+
"package-lock.json": "d7dc3c48d003f2356d961972ad5515f192f2c846af8d0b551fa08d20ce155ede",
|
|
23
|
+
"build.mjs": "68120f85289e9598f440b1e0be506feb50a37897414d234ffc6698b754b2988e"
|
|
24
|
+
},
|
|
25
|
+
"provenance": {
|
|
26
|
+
"statement": "tracked C + declared upstream build glue",
|
|
27
|
+
"compiled": {
|
|
28
|
+
"entwurf_win.c": "tracked source",
|
|
29
|
+
"node-gyp/src/win_delay_load_hook.cc": {
|
|
30
|
+
"version": "13.1.0",
|
|
31
|
+
"sha256": "ec2357ffdf512151c21a52326ad3396aaa650b83e5c4a31153d216a155f68ecc",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"notice": "LICENSES/node-gyp.txt",
|
|
34
|
+
"licenseSha256": "662a1b0115251cfb29c6aed0f221f8847bc49c6365d1c53a62c9f4bccc2489c3"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"declaredLinkInputs": [
|
|
38
|
+
"advapi32.lib",
|
|
39
|
+
"kernel32.lib",
|
|
40
|
+
"node.lib (import library, Node v24.18.1 win-x64)",
|
|
41
|
+
"MSVC static CRT (/MT)"
|
|
42
|
+
],
|
|
43
|
+
"headers": "Node v24.18.1 headers tarball (MIT), declarations only"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junghanacs/entwurf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Visible sibling sessions across independent agent harnesses: launch in Herdr or tmux, reopen pi in tmux, and deliver messages by garden id; native bridges and Claude/Cortex ACP adapters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"files": [
|
|
42
42
|
"protocol.js",
|
|
43
43
|
"pi-extensions/",
|
|
44
|
+
"native/win32-x64/",
|
|
45
|
+
"LICENSES/node-gyp.txt",
|
|
44
46
|
"mcp/",
|
|
45
47
|
"scripts/",
|
|
46
48
|
"prompts/",
|
|
@@ -193,8 +195,8 @@
|
|
|
193
195
|
"check:full": "bash scripts/check-elapsed.sh check:full check:core check:hermetic check:package",
|
|
194
196
|
"check:core": "pnpm run check:toolchain && pnpm run check:vitest && pnpm run check:contracts",
|
|
195
197
|
"check:toolchain": "pnpm lint && pnpm typecheck",
|
|
196
|
-
"check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-pi-queue-oracle && ./run.sh check-tests-beside-behavior",
|
|
197
|
-
"check:contracts": "./run.sh check-demo-reproducibility && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-acp-foreground-only && ./run.sh check-acp-set-model-readback && ./run.sh check-acp-vendor-rejection && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push && ./run.sh check-pi-durable-beside && ./run.sh check-pi-durable-carrier && ./run.sh check-pi-durable-native-module",
|
|
198
|
+
"check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-pi-queue-oracle && ./run.sh check-tests-beside-behavior && ./run.sh check-remote-pair-installed-audit",
|
|
199
|
+
"check:contracts": "./run.sh check-demo-reproducibility && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-win32-preview && ./run.sh check-pack-win32-carrier && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-acp-foreground-only && ./run.sh check-acp-set-model-readback && ./run.sh check-acp-vendor-rejection && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push && ./run.sh check-pi-durable-beside && ./run.sh check-pi-durable-carrier && ./run.sh check-pi-durable-native-module",
|
|
198
200
|
"check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-pi-launch && ./run.sh check-codex-app-server-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-birth-hook && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-peer-facts && ./run.sh check-herdr-plugin && ./run.sh check-herdr-plugin-profile && ./run.sh check-herdr-plugin-build && ./run.sh check-herdr-runtime-bootstrap && ./run.sh check-herdr-activation && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-typing-call-fence && ./run.sh check-gate-manifests && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-codex-bridge-identity && ./run.sh check-pi-bridge-sender && ./run.sh check-pi-mcp-register && ./run.sh check-pi-mcp-bridge && ./run.sh check-codex-birth-hook && ./run.sh smoke-codex-config-state && ./run.sh smoke-codex-birth && ./run.sh check-herdr-sandbox",
|
|
199
201
|
"check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-pack-consumer-roots && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
|
|
200
202
|
},
|
|
@@ -147,8 +147,12 @@ async function ensureControlDir(): Promise<void> {
|
|
|
147
147
|
await fs.mkdir(ENTWURF_DIR, { recursive: true });
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// On Windows the control address is a named pipe (`\\.\pipe\…`, #78): the kernel owns the
|
|
151
|
+
// name and drops it with the last handle, so there is no file to unlink and none to sweep.
|
|
152
|
+
const PIPE_ADDRESSED = process.platform === "win32";
|
|
153
|
+
|
|
150
154
|
async function removeSocket(socketPath: string | null): Promise<void> {
|
|
151
|
-
if (!socketPath) return;
|
|
155
|
+
if (!socketPath || PIPE_ADDRESSED) return;
|
|
152
156
|
try {
|
|
153
157
|
await fs.unlink(socketPath);
|
|
154
158
|
} catch (error) {
|
|
@@ -162,6 +166,7 @@ async function removeSocket(socketPath: string | null): Promise<void> {
|
|
|
162
166
|
// alias-era artifacts. Runs once per startControlServer call. We only touch
|
|
163
167
|
// entries that are demonstrably dead — a live peer's socket survives.
|
|
164
168
|
async function gcStaleSockets(): Promise<void> {
|
|
169
|
+
if (PIPE_ADDRESSED) return;
|
|
165
170
|
let entries: import("node:fs").Dirent[];
|
|
166
171
|
try {
|
|
167
172
|
entries = await fs.readdir(ENTWURF_DIR, { withFileTypes: true });
|
|
@@ -24,9 +24,18 @@
|
|
|
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
|
*/
|
|
29
36
|
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import * as fs from "node:fs";
|
|
30
39
|
import * as path from "node:path";
|
|
31
40
|
|
|
32
41
|
/** Control-socket filename suffix. The stem is a bare garden id. */
|
|
@@ -51,9 +60,55 @@ export function defaultControlSocketDir(home) {
|
|
|
51
60
|
* @returns {string}
|
|
52
61
|
*/
|
|
53
62
|
export function controlSocketPathIn(dir, gardenId) {
|
|
63
|
+
if (process.platform === "win32") return win32PipeNameFor(dir, gardenId);
|
|
54
64
|
return path.join(dir, `${gardenId}${CONTROL_SOCKET_SUFFIX}`);
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** Every Windows control pipe this package names starts here. */
|
|
68
|
+
export const WIN32_PIPE_PREFIX = "\\\\.\\pipe\\entwurf-";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The one spelling of a Windows store root, so Pi A, Pi B, the bridge and peer-facts
|
|
72
|
+
* derive the same pipe name from differently written paths. The nearest EXISTING ancestor
|
|
73
|
+
* of `path.win32.resolve(dir)` goes through `realpath` (8.3 names expanded, case from the
|
|
74
|
+
* filesystem); the components below it are appended as written. Separators become `\`,
|
|
75
|
+
* there is no trailing separator, and the whole string is lower-cased. Nothing is created
|
|
76
|
+
* or touched, so the control dir need not exist yet. A failure other than ENOENT throws.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} dir
|
|
79
|
+
* @param {(p: string) => string} [realpath]
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function canonRootWin32(dir, realpath = fs.realpathSync.native) {
|
|
83
|
+
let head = path.win32.resolve(dir);
|
|
84
|
+
/** @type {string[]} */
|
|
85
|
+
const tail = [];
|
|
86
|
+
for (;;) {
|
|
87
|
+
try {
|
|
88
|
+
const joined = path.win32.join(realpath(head), ...tail);
|
|
89
|
+
return joined.replace(/[\\/]+$/, "").toLowerCase();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const parent = path.win32.dirname(head);
|
|
92
|
+
if (/** @type {{ code?: unknown }} */ (err).code !== "ENOENT" || parent === head) throw err;
|
|
93
|
+
tail.unshift(path.win32.basename(head));
|
|
94
|
+
head = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Forward grammar on Windows: `\\.\pipe\entwurf-<16 hex>-<gardenId>`.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} dir
|
|
103
|
+
* @param {string} gardenId
|
|
104
|
+
* @param {(p: string) => string} [realpath]
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function win32PipeNameFor(dir, gardenId, realpath) {
|
|
108
|
+
const digest = createHash("sha256").update(canonRootWin32(dir, realpath), "utf8").digest("hex").slice(0, 16);
|
|
109
|
+
return `${WIN32_PIPE_PREFIX}${digest}-${gardenId}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
/**
|
|
58
113
|
* Inverse grammar: a socket filename back to its garden id, or `null` when the
|
|
59
114
|
* name does not carry the suffix.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as net from "node:net";
|
|
18
|
+
import { win32Native } from "./win32-native.js";
|
|
18
19
|
|
|
19
20
|
// `sender` carries the transparency envelope (agentId / sessionId / cwd /
|
|
20
21
|
// timestamp). All four fields are mandatory whenever `sender` is present —
|
|
@@ -199,6 +200,7 @@ export async function sendRpcCommand(
|
|
|
199
200
|
options: RpcClientOptions = {},
|
|
200
201
|
): Promise<{ response: RpcResponse }> {
|
|
201
202
|
const { timeout = 5000 } = options;
|
|
203
|
+
if (process.platform === "win32") return await sendPipeRpcCommand(socketPath, command, timeout);
|
|
202
204
|
|
|
203
205
|
return new Promise((resolve, reject) => {
|
|
204
206
|
const socket = net.createConnection(socketPath);
|
|
@@ -277,3 +279,33 @@ export async function sendRpcCommand(
|
|
|
277
279
|
});
|
|
278
280
|
});
|
|
279
281
|
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The Windows client (#78). The addon makes one owner-validated connect, writes the request
|
|
285
|
+
* line and returns the first response line, all inside one deadline; a timeout is
|
|
286
|
+
* `ETIMEDOUT`, a pipe owned by anyone but this token's expected owner is `EPERM` before any
|
|
287
|
+
* byte. The protocol is request-bound on its own connection, so that first line IS the
|
|
288
|
+
* answer: anything other than this command's response is refused, never waited past.
|
|
289
|
+
*/
|
|
290
|
+
async function sendPipeRpcCommand(
|
|
291
|
+
pipeName: string,
|
|
292
|
+
command: RpcCommand,
|
|
293
|
+
timeout: number,
|
|
294
|
+
): Promise<{ response: RpcResponse }> {
|
|
295
|
+
const { responseLine } = await win32Native().pipeRpc(
|
|
296
|
+
pipeName,
|
|
297
|
+
JSON.stringify(command),
|
|
298
|
+
Math.max(0, Math.floor(timeout)),
|
|
299
|
+
);
|
|
300
|
+
let msg: unknown;
|
|
301
|
+
try {
|
|
302
|
+
msg = JSON.parse(responseLine.trim());
|
|
303
|
+
} catch {
|
|
304
|
+
throw new Error(`control pipe answered ${command.type} with a line that is not JSON`);
|
|
305
|
+
}
|
|
306
|
+
const response = msg as RpcResponse & { type?: unknown };
|
|
307
|
+
if (response?.type !== "response" || response.command !== command.type) {
|
|
308
|
+
throw new Error(`control pipe answered something other than the ${command.type} response`);
|
|
309
|
+
}
|
|
310
|
+
return { response };
|
|
311
|
+
}
|
|
@@ -35,6 +35,9 @@ export interface MailboxSenderEnvelope {
|
|
|
35
35
|
timestamp: string; // ISO 8601 UTC
|
|
36
36
|
origin?: "pi-session" | "external-mcp" | "meta-session";
|
|
37
37
|
replyable?: boolean;
|
|
38
|
+
/** #107: set only by the receiving half of a declared remote pair. `via` is the route label;
|
|
39
|
+
* `replyRoute` says whether this host declares a route back. Neither is a probe. */
|
|
40
|
+
remote?: { via: string; replyRoute: "declared" | "none" };
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/** `~`-abbreviate a home-relative cwd for display. Reads process.env.HOME. */
|
|
@@ -72,14 +75,20 @@ export function formatMetaMailboxBody(sender: MailboxSenderEnvelope, message: st
|
|
|
72
75
|
const replyable = sender.replyable === true;
|
|
73
76
|
const isMeta = sender.origin === "meta-session";
|
|
74
77
|
const kind = isMeta ? "meta-session, " : "";
|
|
78
|
+
// A remote sender is never replyable here: no reply route was probed. Its cwd is a path on the
|
|
79
|
+
// SENDING host, so it is neither abbreviated against this host's HOME nor shown as a local path.
|
|
80
|
+
const remote = !replyable ? sender.remote : undefined;
|
|
75
81
|
const sessionLine = replyable
|
|
76
82
|
? `${sender.sessionId} (${kind}replyable — reply via entwurf_v2 to this sessionId, intent=fire-and-forget)`
|
|
77
|
-
:
|
|
78
|
-
? `${sender.sessionId} (
|
|
79
|
-
:
|
|
83
|
+
: remote
|
|
84
|
+
? `${sender.sessionId} (remote via ${remote.via}; ${remote.replyRoute === "declared" ? "reply route declared, not probed" : "no reply route declared"})`
|
|
85
|
+
: isMeta
|
|
86
|
+
? `${sender.sessionId} (meta-session, non-replyable)`
|
|
87
|
+
: `${sender.sessionId} (external, non-replyable)`;
|
|
88
|
+
const where = remote ? `${sender.cwd} (sender-host path)` : abbreviateHome(sender.cwd);
|
|
80
89
|
return (
|
|
81
90
|
`[entwurf received ⟵]\n` +
|
|
82
|
-
` from: ${sender.agentId} @ ${
|
|
91
|
+
` from: ${sender.agentId} @ ${where}\n` +
|
|
83
92
|
` session: ${sessionLine}\n` +
|
|
84
93
|
` at: ${formatKstTimestamp(sender.timestamp)}\n` +
|
|
85
94
|
` wants reply: ${wantsReply ? "yes" : "no"}\n` +
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
import { execFileSync } from "node:child_process";
|
|
54
54
|
import * as crypto from "node:crypto";
|
|
55
55
|
import * as fs from "node:fs";
|
|
56
|
+
import { createRequire } from "node:module";
|
|
56
57
|
import * as os from "node:os";
|
|
57
58
|
import * as path from "node:path";
|
|
58
59
|
import { generateSessionId, SESSION_ID_RE } from "./session-id.js";
|
|
@@ -1017,6 +1018,7 @@ const O_NONBLOCK_FLAG = typeof fs.constants.O_NONBLOCK === "number" ? fs.constan
|
|
|
1017
1018
|
* Shaping a message is the job of the caller that knows what the answer is FOR.
|
|
1018
1019
|
*/
|
|
1019
1020
|
export function readStoreRecordFile(file: string): string {
|
|
1021
|
+
if (process.platform === "win32") return win32RecordReader()(file).toString("utf8");
|
|
1020
1022
|
if (O_NOFOLLOW_FLAG === 0) {
|
|
1021
1023
|
throw new MetaRecordError(
|
|
1022
1024
|
`cannot read meta-record ${path.basename(file)} safely: this platform exposes no O_NOFOLLOW, so a record ` +
|
|
@@ -1039,6 +1041,18 @@ export function readStoreRecordFile(file: string): string {
|
|
|
1039
1041
|
}
|
|
1040
1042
|
}
|
|
1041
1043
|
|
|
1044
|
+
/**
|
|
1045
|
+
* Windows has no `O_NOFOLLOW` (libuv defines it as 0), so the same three decisions — a
|
|
1046
|
+
* final-component reparse point refused as `ELOOP`, a non-regular entry as
|
|
1047
|
+
* `ENTWURF_ENOTREG`, both on the handle the read uses — are made by the preview addon
|
|
1048
|
+
* (#78). It is reached only here and only on win32, through `createRequire`, so the
|
|
1049
|
+
* installers that copy this file without its siblings never load it on Linux or macOS.
|
|
1050
|
+
*/
|
|
1051
|
+
function win32RecordReader(): (file: string) => Buffer {
|
|
1052
|
+
const native = createRequire(import.meta.url)("./win32-native.js") as typeof import("./win32-native.js");
|
|
1053
|
+
return native.win32ReadNoFollow;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1042
1056
|
/** The reader every store-WIDE scan pairs with {@link readActiveStoreEntries}. The
|
|
1043
1057
|
* caller's entry kind decides the POLICY — whether a non-regular entry is a defect
|
|
1044
1058
|
* (certification), a diagnostic (listing) or a non-candidate (the rival scan) — because
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
|
|
27
|
+
import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
|
|
28
|
+
import type { EntwurfV2SurfaceRendered, SurfaceEntwurfV2Params } from "./entwurf-v2-surface.ts";
|
|
29
|
+
import type { MailboxSenderEnvelope } from "./meta-mailbox-body.ts";
|
|
30
|
+
import { requireGardenId } from "./meta-session.ts";
|
|
31
|
+
|
|
32
|
+
/** One operator-declared pair, as written on the RECEIVING host. */
|
|
33
|
+
export interface RemotePairDeclaration {
|
|
34
|
+
/** The sender's garden id on the other host. */
|
|
35
|
+
remoteGardenId: string;
|
|
36
|
+
/** The SSH alias this host uses to reach it (route label; never an identity). */
|
|
37
|
+
ssh: string;
|
|
38
|
+
/** The garden id on THIS host the remote sender may address. */
|
|
39
|
+
localGardenId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the sending host writes to stdin. */
|
|
43
|
+
export interface RemoteClaim {
|
|
44
|
+
from: string;
|
|
45
|
+
fromAgentId: string;
|
|
46
|
+
/** A path on the SENDING host. Shown, never checked here. */
|
|
47
|
+
fromCwd: string;
|
|
48
|
+
target: string;
|
|
49
|
+
message: string;
|
|
50
|
+
wantsReply: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type RemotePairRejectReason = "remote-pair-from-mismatch" | "remote-pair-target-mismatch";
|
|
54
|
+
|
|
55
|
+
export type RemoteReceiveReceipt =
|
|
56
|
+
| { kind: "rejected"; reason: RemotePairRejectReason; detail: string }
|
|
57
|
+
| { kind: "dispatched"; isError: boolean; text: string };
|
|
58
|
+
|
|
59
|
+
export interface RemoteReceiveDeps {
|
|
60
|
+
dispatch: (params: SurfaceEntwurfV2Params, sender: SenderEnvelope) => Promise<EntwurfV2SurfaceRendered>;
|
|
61
|
+
now?: () => Date;
|
|
62
|
+
/** Whether THIS host declares a route back to the sender. Shown, never probed. Default: none. */
|
|
63
|
+
replyRoute?: "declared" | "none";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MESSAGE_CAP = 16000;
|
|
67
|
+
|
|
68
|
+
/** Every Unicode control character (the class herdr-fresh-call.ts refuses). A header field is
|
|
69
|
+
* rendered unescaped into the body's `[entwurf received]` block, so a newline in one could forge a
|
|
70
|
+
* `session:` or `wants reply:` line. The message body keeps its newlines. */
|
|
71
|
+
const CONTROL_CHAR = /\p{Cc}/u;
|
|
72
|
+
|
|
73
|
+
export class RemotePairInputError extends Error {}
|
|
74
|
+
|
|
75
|
+
function parseObject(text: string, what: string): Record<string, unknown> {
|
|
76
|
+
let raw: unknown;
|
|
77
|
+
try {
|
|
78
|
+
raw = JSON.parse(text);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new RemotePairInputError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
81
|
+
}
|
|
82
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
83
|
+
throw new RemotePairInputError(`${what}: must be a JSON object`);
|
|
84
|
+
}
|
|
85
|
+
return raw as Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireExactKeys(obj: Record<string, unknown>, keys: readonly string[], what: string): void {
|
|
89
|
+
const got = Object.keys(obj).sort();
|
|
90
|
+
const want = [...keys].sort();
|
|
91
|
+
if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
|
|
92
|
+
throw new RemotePairInputError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireString(obj: Record<string, unknown>, key: string, what: string): string {
|
|
97
|
+
const v = obj[key];
|
|
98
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
99
|
+
throw new RemotePairInputError(`${what}: "${key}" must be a non-empty string`);
|
|
100
|
+
}
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A field that lands in the rendered sender header: non-empty and free of control characters. */
|
|
105
|
+
function requireHeaderString(obj: Record<string, unknown>, key: string, what: string): string {
|
|
106
|
+
const v = requireString(obj, key, what);
|
|
107
|
+
if (CONTROL_CHAR.test(v)) {
|
|
108
|
+
throw new RemotePairInputError(`${what}: "${key}" must not contain control characters`);
|
|
109
|
+
}
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseRemoteClaim(text: string): RemoteClaim {
|
|
114
|
+
const what = "remote claim";
|
|
115
|
+
const obj = parseObject(text, what);
|
|
116
|
+
requireExactKeys(obj, ["from", "fromAgentId", "fromCwd", "target", "message", "wantsReply"], what);
|
|
117
|
+
const message = requireString(obj, "message", what);
|
|
118
|
+
if (message.length > MESSAGE_CAP) {
|
|
119
|
+
throw new RemotePairInputError(`${what}: "message" exceeds ${MESSAGE_CAP} chars`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof obj.wantsReply !== "boolean") {
|
|
122
|
+
throw new RemotePairInputError(`${what}: "wantsReply" must be a boolean`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
from: requireGardenId(obj.from),
|
|
126
|
+
fromAgentId: requireHeaderString(obj, "fromAgentId", what),
|
|
127
|
+
fromCwd: requireHeaderString(obj, "fromCwd", what),
|
|
128
|
+
target: requireGardenId(obj.target),
|
|
129
|
+
message,
|
|
130
|
+
wantsReply: obj.wantsReply,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The scope check. Null = inside the declared pair. */
|
|
135
|
+
export function checkRemotePair(
|
|
136
|
+
claim: RemoteClaim,
|
|
137
|
+
decl: RemotePairDeclaration,
|
|
138
|
+
): { reason: RemotePairRejectReason; detail: string } | null {
|
|
139
|
+
if (claim.from !== decl.remoteGardenId) {
|
|
140
|
+
return {
|
|
141
|
+
reason: "remote-pair-from-mismatch",
|
|
142
|
+
detail: `from ${claim.from} is not the declared remote ${decl.remoteGardenId}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (claim.target !== decl.localGardenId) {
|
|
146
|
+
return {
|
|
147
|
+
reason: "remote-pair-target-mismatch",
|
|
148
|
+
detail: `target ${claim.target} is not the declared local ${decl.localGardenId}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildRemoteSenderEnvelope(
|
|
155
|
+
claim: RemoteClaim,
|
|
156
|
+
decl: RemotePairDeclaration,
|
|
157
|
+
now: Date,
|
|
158
|
+
replyRoute: "declared" | "none" = "none",
|
|
159
|
+
): SenderEnvelope & MailboxSenderEnvelope {
|
|
160
|
+
return {
|
|
161
|
+
sessionId: claim.from,
|
|
162
|
+
agentId: claim.fromAgentId,
|
|
163
|
+
cwd: claim.fromCwd,
|
|
164
|
+
timestamp: now.toISOString(),
|
|
165
|
+
origin: "external-mcp",
|
|
166
|
+
replyable: false,
|
|
167
|
+
remote: { via: decl.ssh, replyRoute },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function receiveRemoteClaim(
|
|
172
|
+
claim: RemoteClaim,
|
|
173
|
+
decl: RemotePairDeclaration,
|
|
174
|
+
deps: RemoteReceiveDeps,
|
|
175
|
+
): Promise<RemoteReceiveReceipt> {
|
|
176
|
+
const mismatch = checkRemotePair(claim, decl);
|
|
177
|
+
if (mismatch) return { kind: "rejected", ...mismatch };
|
|
178
|
+
const sender = buildRemoteSenderEnvelope(claim, decl, (deps.now ?? (() => new Date()))(), deps.replyRoute);
|
|
179
|
+
const rendered = await deps.dispatch(
|
|
180
|
+
{ target: claim.target, intent: "fire-and-forget", message: claim.message, wants_reply: claim.wantsReply },
|
|
181
|
+
sender,
|
|
182
|
+
);
|
|
183
|
+
return { kind: "dispatched", isError: rendered.isError, text: rendered.text };
|
|
184
|
+
}
|