@junghanacs/entwurf 0.39.0 → 0.41.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/AGENTS.md +4 -2
- package/BASELINE.md +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- 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 +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -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/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -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/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +122 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +20 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +2 -2
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- 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/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/remote-pair-receive.ts +73 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -396,7 +396,23 @@ const META_IDENTITY_KEYS = [
|
|
|
396
396
|
"transcriptPath",
|
|
397
397
|
"createdAt",
|
|
398
398
|
"recordUpdatedAt",
|
|
399
|
+
"attachIncarnation",
|
|
399
400
|
];
|
|
401
|
+
/** `attachIncarnation` grammar: 32 lowercase hex characters (`randomBytes(16)`). */
|
|
402
|
+
const ATTACH_INCARNATION_RE = /^[0-9a-f]{32}$/;
|
|
403
|
+
/** Validate an `attachIncarnation`: null, or the 32-hex grammar. Throws on anything else. */
|
|
404
|
+
export function requireAttachIncarnation(value) {
|
|
405
|
+
if (value === null)
|
|
406
|
+
return null;
|
|
407
|
+
if (typeof value !== "string" || !ATTACH_INCARNATION_RE.test(value)) {
|
|
408
|
+
throw new MetaRecordError(`meta-record field "attachIncarnation" must be null or 32 lowercase hex characters (got ${describe(value)}).`);
|
|
409
|
+
}
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
/** Mint one arm binding value. Clock-independent: 128 random bits, never derived from time. */
|
|
413
|
+
export function mintAttachIncarnation() {
|
|
414
|
+
return crypto.randomBytes(16).toString("hex");
|
|
415
|
+
}
|
|
400
416
|
/** Parse + fully validate untrusted JSON into a v3 MetaIdentity. Throws on any drift. */
|
|
401
417
|
export function parseMetaRecordV3(json) {
|
|
402
418
|
let raw;
|
|
@@ -432,6 +448,10 @@ export function parseMetaRecordV3(json) {
|
|
|
432
448
|
transcriptPath: requireNullableString(obj.transcriptPath, "transcriptPath"),
|
|
433
449
|
createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
|
|
434
450
|
recordUpdatedAt: requireNonEmptyString(obj.recordUpdatedAt, "recordUpdatedAt"),
|
|
451
|
+
// The one key a V3 record may omit: a record nothing has touched since the field
|
|
452
|
+
// existed reads its absence as null ("no binding observed"). One schema, one reader —
|
|
453
|
+
// not a legacy alias: every serialize emits the key.
|
|
454
|
+
attachIncarnation: requireAttachIncarnation(obj.attachIncarnation === undefined ? null : obj.attachIncarnation),
|
|
435
455
|
};
|
|
436
456
|
}
|
|
437
457
|
/**
|
|
@@ -450,6 +470,7 @@ export function normalizeMetaIdentity(record) {
|
|
|
450
470
|
transcriptPath: record.transcriptPath,
|
|
451
471
|
createdAt: record.createdAt,
|
|
452
472
|
recordUpdatedAt: record.recordUpdatedAt,
|
|
473
|
+
attachIncarnation: record.attachIncarnation,
|
|
453
474
|
};
|
|
454
475
|
}
|
|
455
476
|
// ---------------------------------------------------------------------------
|
|
@@ -474,6 +495,7 @@ export function serializeMetaIdentity(identity) {
|
|
|
474
495
|
transcriptPath: identity.transcriptPath,
|
|
475
496
|
createdAt: identity.createdAt,
|
|
476
497
|
recordUpdatedAt: identity.recordUpdatedAt,
|
|
498
|
+
attachIncarnation: identity.attachIncarnation,
|
|
477
499
|
};
|
|
478
500
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
479
501
|
}
|
|
@@ -497,6 +519,7 @@ export function mintMetaIdentity(input, now = new Date()) {
|
|
|
497
519
|
transcriptPath: requireNullableString(input.transcriptPath ?? null, "transcriptPath"),
|
|
498
520
|
createdAt: ts,
|
|
499
521
|
recordUpdatedAt: ts,
|
|
522
|
+
attachIncarnation: input.attachIncarnation === undefined ? null : requireAttachIncarnation(input.attachIncarnation),
|
|
500
523
|
};
|
|
501
524
|
}
|
|
502
525
|
/**
|
|
@@ -1097,9 +1120,13 @@ export function decideUpsert(existing, input, now = new Date()) {
|
|
|
1097
1120
|
const transcriptPath = input.transcriptPath === undefined
|
|
1098
1121
|
? existing.transcriptPath
|
|
1099
1122
|
: requireNullableString(input.transcriptPath, "transcriptPath");
|
|
1123
|
+
// The arm binding is not a merge axis: only a mint replaces it, and nothing clears it.
|
|
1124
|
+
const attachIncarnation = input.attachIncarnation === undefined
|
|
1125
|
+
? existing.attachIncarnation
|
|
1126
|
+
: requireAttachIncarnation(input.attachIncarnation);
|
|
1100
1127
|
return {
|
|
1101
1128
|
action: "attach",
|
|
1102
|
-
record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now) },
|
|
1129
|
+
record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now), attachIncarnation },
|
|
1103
1130
|
};
|
|
1104
1131
|
}
|
|
1105
1132
|
// ---------------------------------------------------------------------------
|
|
@@ -1196,6 +1223,16 @@ export function defaultMetaReceiversDir() {
|
|
|
1196
1223
|
return path.resolve(expandTilde(process.env.ENTWURF_META_RECEIVERS_DIR));
|
|
1197
1224
|
return path.join(piAgentDir(), "meta-receivers");
|
|
1198
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* The record-arm lock directory (#147 L2 B1): a sibling of the receivers dir, so a
|
|
1228
|
+
* sandboxed receivers root sandboxes it too and no new environment carrier exists. It is
|
|
1229
|
+
* deliberately NOT the v2 dispatch lock dir — visible resume holds that per-gid lock across
|
|
1230
|
+
* its whole launch + observation window, and the relaunched session's own arm must not wait
|
|
1231
|
+
* on it. Only the Claude Code hook takes this lock; nothing else reads or writes here.
|
|
1232
|
+
*/
|
|
1233
|
+
export function defaultMetaRecordArmLocksDir(receiversDir = defaultMetaReceiversDir()) {
|
|
1234
|
+
return path.join(path.dirname(receiversDir), "meta-record-arm-locks");
|
|
1235
|
+
}
|
|
1199
1236
|
/**
|
|
1200
1237
|
* The exact provenance label the omp-native MCP entry carries
|
|
1201
1238
|
* (`scripts/omp-mcp-config.py` `EXTERNAL_AGENT_ID`). It is what lets a bridge CHILD know
|
|
@@ -1652,12 +1689,38 @@ export function writeMetaReceiverMarker(opts) {
|
|
|
1652
1689
|
ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
|
|
1653
1690
|
armProvenance: requireArmProvenance(opts.armProvenance),
|
|
1654
1691
|
updatedAt: isoNow(opts.now ?? new Date()),
|
|
1692
|
+
attachIncarnation: opts.attachIncarnation === undefined ? null : requireAttachIncarnation(opts.attachIncarnation),
|
|
1655
1693
|
};
|
|
1656
1694
|
const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
1657
1695
|
fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
1658
1696
|
fs.renameSync(tmp, file);
|
|
1659
1697
|
return file;
|
|
1660
1698
|
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Validate receiver-marker bytes — the one validator, shared by the path reader below and by
|
|
1701
|
+
* the no-follow fd reader visible resume uses (#147 L2 D3). Throws on a malformed marker;
|
|
1702
|
+
* returns null for an IMPLAUSIBLE owner pid. Same rule as the sender marker, and for the same
|
|
1703
|
+
* reason one layer over: an impossible owner is not a live one, so a refuted marker must never
|
|
1704
|
+
* read back as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void. An
|
|
1705
|
+
* absent `attachIncarnation` reads as null; a present malformed one throws.
|
|
1706
|
+
*/
|
|
1707
|
+
export function parseMetaReceiverMarker(json) {
|
|
1708
|
+
const raw = JSON.parse(json);
|
|
1709
|
+
const marker = {
|
|
1710
|
+
gardenId: requireGardenId(raw.gardenId),
|
|
1711
|
+
backend: requireBackend(raw.backend),
|
|
1712
|
+
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
1713
|
+
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
1714
|
+
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1715
|
+
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
1716
|
+
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
1717
|
+
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1718
|
+
attachIncarnation: requireAttachIncarnation(raw.attachIncarnation === undefined ? null : raw.attachIncarnation),
|
|
1719
|
+
};
|
|
1720
|
+
if (!isPlausibleOwnerPid(marker.ownerPid))
|
|
1721
|
+
return null;
|
|
1722
|
+
return marker;
|
|
1723
|
+
}
|
|
1661
1724
|
/**
|
|
1662
1725
|
* Read the receiver presence marker for a garden id. Returns null when absent,
|
|
1663
1726
|
* corrupt, or (under verifyOwner) the owner pid is no longer the same live process —
|
|
@@ -1674,21 +1737,8 @@ export function readMetaReceiverMarker(opts) {
|
|
|
1674
1737
|
if (!file || !fs.existsSync(file))
|
|
1675
1738
|
return null;
|
|
1676
1739
|
try {
|
|
1677
|
-
const
|
|
1678
|
-
|
|
1679
|
-
gardenId: requireGardenId(raw.gardenId),
|
|
1680
|
-
backend: requireBackend(raw.backend),
|
|
1681
|
-
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
1682
|
-
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
1683
|
-
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1684
|
-
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
1685
|
-
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
1686
|
-
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1687
|
-
};
|
|
1688
|
-
// Same rule as the sender marker, and for the same reason one layer over: an
|
|
1689
|
-
// impossible owner is not a live one, so a refuted marker must never read back
|
|
1690
|
-
// as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void.
|
|
1691
|
-
if (!isPlausibleOwnerPid(marker.ownerPid))
|
|
1740
|
+
const marker = parseMetaReceiverMarker(fs.readFileSync(file, "utf8"));
|
|
1741
|
+
if (marker === null)
|
|
1692
1742
|
return null;
|
|
1693
1743
|
if (opts.verifyOwner !== false) {
|
|
1694
1744
|
const liveKey = processStartKey(marker.ownerPid);
|
|
@@ -1774,6 +1824,9 @@ export function upsertMetaSession(opts) {
|
|
|
1774
1824
|
const target = requireNonEmptyString(opts.input.nativeSessionId, "nativeSessionId");
|
|
1775
1825
|
const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
1776
1826
|
const decision = decideUpsert(existing, opts.input, opts.now);
|
|
1827
|
+
if (opts.expectAction !== undefined && decision.action !== opts.expectAction) {
|
|
1828
|
+
throw new UpsertActionMismatchError(opts.expectAction, decision.action, target);
|
|
1829
|
+
}
|
|
1777
1830
|
const file = path.join(dir, metaRecordFilename(decision.record));
|
|
1778
1831
|
if (decision.action === "create")
|
|
1779
1832
|
publishExclusiveIdentity(file, decision.record);
|
|
@@ -1781,6 +1834,33 @@ export function upsertMetaSession(opts) {
|
|
|
1781
1834
|
atomicWriteIdentity(file, decision.record);
|
|
1782
1835
|
return { action: decision.action, record: decision.record, dir, path: file };
|
|
1783
1836
|
}
|
|
1837
|
+
/** The upsert refused before writing because its decision was not the expected action. */
|
|
1838
|
+
export class UpsertActionMismatchError extends MetaRecordError {
|
|
1839
|
+
expected;
|
|
1840
|
+
actual;
|
|
1841
|
+
constructor(expected, actual, nativeSessionId) {
|
|
1842
|
+
super(`upsert expected to ${expected} the record for nativeSessionId "${nativeSessionId}" but would ${actual} ` +
|
|
1843
|
+
"it; nothing was written.");
|
|
1844
|
+
this.expected = expected;
|
|
1845
|
+
this.actual = actual;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* The certified record a native session id already has, READ ONLY — the same certification
|
|
1850
|
+
* {@link upsertMetaSession} runs (a defective store throws the same refusal), with no write.
|
|
1851
|
+
* The Claude Code hook learns the garden id it must lock this way, so no record write ever
|
|
1852
|
+
* happens outside the record-arm lock for an existing record (#147 L2 B1).
|
|
1853
|
+
*/
|
|
1854
|
+
export function findCertifiedIdentityByNativeSessionId(nativeSessionId, dirOverride) {
|
|
1855
|
+
const dir = path.resolve(expandTilde(dirOverride ?? defaultMetaSessionsDir()));
|
|
1856
|
+
if (!fs.existsSync(dir))
|
|
1857
|
+
return null;
|
|
1858
|
+
const cert = certifyActiveStoreDir(dir);
|
|
1859
|
+
if (cert.defects.length > 0)
|
|
1860
|
+
throw new MetaRecordError(activeStoreRefusal(cert));
|
|
1861
|
+
const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
|
|
1862
|
+
return cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
1863
|
+
}
|
|
1784
1864
|
/**
|
|
1785
1865
|
* ATTACH replace primitive: tmp-file + rename so a crash never leaves a
|
|
1786
1866
|
* half-written record (#30 crash-safety). rename(2) REPLACES an existing final
|
|
@@ -91,8 +91,8 @@ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
|
|
|
91
91
|
import { CODEX_CALLER_SEAT_HINT, resolveCodexCallerSeat, } from "./codex-caller-seat.js";
|
|
92
92
|
import { CODEX_CALLER_PREFLIGHT_HINT, CODEX_LAUNCH_CWD_PREFLIGHT_HINT, CODEX_PREFLIGHT_HINT, codexLaunchCwdFreshPreflight, } from "./codex-fresh-preflight.js";
|
|
93
93
|
import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
|
|
94
|
-
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, FRESH_CALL_DELIVERY_TOOL, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
95
|
-
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
|
|
94
|
+
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, FRESH_CALL_DELIVERY_TOOL, FRESH_CALL_PROVIDER_HINT, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
95
|
+
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
|
|
96
96
|
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
97
97
|
import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
|
|
98
98
|
import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
|
|
@@ -188,33 +188,8 @@ export function selectFreshCallSeat(placement) {
|
|
|
188
188
|
return { tmuxSession: placement.tmuxSession, source: "requested" };
|
|
189
189
|
return null;
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
*
|
|
194
|
-
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
195
|
-
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
196
|
-
* hands that value to every window it will ever open — the control run printed
|
|
197
|
-
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
198
|
-
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
199
|
-
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
200
|
-
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
201
|
-
* found in the environment, confidently and wrong).
|
|
202
|
-
*
|
|
203
|
-
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
204
|
-
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
205
|
-
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
206
|
-
*
|
|
207
|
-
* It is applied to all six backends because the leak is a property of tmux, not of a vendor. A
|
|
208
|
-
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
209
|
-
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
210
|
-
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
211
|
-
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
212
|
-
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
213
|
-
* rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
|
|
214
|
-
* (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
|
|
215
|
-
* address out of prose.
|
|
216
|
-
*/
|
|
217
|
-
const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
|
|
191
|
+
// The identity scrub every fresh window carries is `SCRUBBED_INHERITED_ENV` (mux-launch.ts), shared
|
|
192
|
+
// with the resume rail because the leak it closes is a property of tmux, not of either verb.
|
|
218
193
|
/** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
|
|
219
194
|
* resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
|
|
220
195
|
* dialect. An omitted cwd adds no `-c` carrier at all.
|
|
@@ -478,6 +453,7 @@ const REJECT_HINT = {
|
|
|
478
453
|
...CODEX_PREFLIGHT_HINT,
|
|
479
454
|
...CODEX_CALLER_PREFLIGHT_HINT,
|
|
480
455
|
...CODEX_CALLER_SEAT_HINT,
|
|
456
|
+
...FRESH_CALL_PROVIDER_HINT,
|
|
481
457
|
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
482
458
|
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
483
459
|
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
@@ -59,6 +59,34 @@ import { APPEND_FORMAT, assertSelector, assertTmuxOk, parseWindowFields, require
|
|
|
59
59
|
/** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
|
|
60
60
|
* is a separate ownership decision (docs §11), not a parameter. */
|
|
61
61
|
export const PI_RUNTIME_COMMAND = "pi";
|
|
62
|
+
/**
|
|
63
|
+
* The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
|
|
64
|
+
*
|
|
65
|
+
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
66
|
+
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
67
|
+
* hands that value to every window it will ever open — the control run printed
|
|
68
|
+
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
69
|
+
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
70
|
+
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
71
|
+
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
72
|
+
* found in the environment, confidently and wrong).
|
|
73
|
+
*
|
|
74
|
+
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
75
|
+
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
76
|
+
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
77
|
+
*
|
|
78
|
+
* It is applied to every fresh backend and to every resumed one (#147 L2) because the leak is a
|
|
79
|
+
* property of tmux, not of a vendor or a verb. A
|
|
80
|
+
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
81
|
+
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
82
|
+
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
83
|
+
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
84
|
+
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
85
|
+
* rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
|
|
86
|
+
* (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
|
|
87
|
+
* address out of prose.
|
|
88
|
+
*/
|
|
89
|
+
export const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
|
|
62
90
|
/** A named precondition failure. Callers surface `reason`; they never soften it into a
|
|
63
91
|
* fallback launch of something else. */
|
|
64
92
|
export class LaunchPreconditionError extends Error {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mux-resume-call — open ONE window in the caller's own tmux session at a cwd this module did
|
|
3
|
-
* not choose, and hand it argv this module did not author.
|
|
3
|
+
* not choose, start the backend's FIXED runtime there, and hand it argv this module did not author.
|
|
4
4
|
*
|
|
5
5
|
* ── Why this is a module and not a parameter on fresh-call ──
|
|
6
6
|
*
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
* visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
|
|
16
16
|
* resume-call -X-> garden identity, records, locks, delivery
|
|
17
17
|
*
|
|
18
|
-
* This module never learns which citizen it is reopening. It sees a cwd and an argv
|
|
18
|
+
* This module never learns which citizen it is reopening. It sees a backend, a cwd and an argv
|
|
19
|
+
* tail; the backend only selects a runtime from a closed table (#147 L2).
|
|
20
|
+
*
|
|
21
|
+
* ── Tmux only, and said so inside herdr ──
|
|
22
|
+
*
|
|
23
|
+
* This is the tmux rail. A caller inside herdr (`HERDR_ENV=1`, herdr's own marker, read for its
|
|
24
|
+
* exact value as `fresh-call-dispatch` does) is refused by name before anything else: herdr owns
|
|
25
|
+
* placement there, and an ambient `TMUX` that happens to be set would otherwise put the window in
|
|
26
|
+
* a session the operator is not looking at. There is no herdr resume rail yet and no fallback.
|
|
19
27
|
*
|
|
20
28
|
* ── The cwd is the whole reason this module owns its own tmux argv ──
|
|
21
29
|
*
|
|
@@ -41,15 +49,18 @@
|
|
|
41
49
|
* to acceptance, not to the product's launch receipt.
|
|
42
50
|
*/
|
|
43
51
|
import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
|
|
44
|
-
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
|
|
52
|
+
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
|
|
45
53
|
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
46
|
-
/** The fixed runtime
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
export const RESUME_CALL_RUNTIME = "pi";
|
|
54
|
+
/** The fixed runtime each resumable backend reopens with. A closed table, not a command input: the
|
|
55
|
+
* backend boundary is enforced upstream by `resolveResumeLaunchIdentity`, and this is the mux half
|
|
56
|
+
* of the same fact. */
|
|
57
|
+
export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" };
|
|
50
58
|
/**
|
|
51
|
-
* Launch argv: the leaf's detached-append shape
|
|
52
|
-
*
|
|
59
|
+
* Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
|
|
60
|
+
* the same two assignments every fresh window carries), `-c`, the runtime, then the caller's flags.
|
|
61
|
+
* `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
|
|
62
|
+
* carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
|
|
63
|
+
* (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
|
|
53
64
|
*/
|
|
54
65
|
export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
55
66
|
assertSelector("session", placement.sessionId);
|
|
@@ -61,6 +72,7 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
|
61
72
|
"new-window",
|
|
62
73
|
"-d",
|
|
63
74
|
"-a",
|
|
75
|
+
...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
|
|
64
76
|
"-t",
|
|
65
77
|
`${placement.sessionId}:{end}`,
|
|
66
78
|
"-c",
|
|
@@ -81,12 +93,14 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
|
81
93
|
* `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
|
|
82
94
|
*/
|
|
83
95
|
export function resumeCall(params, env = process.env) {
|
|
96
|
+
if (env.HERDR_ENV === "1")
|
|
97
|
+
return { ok: false, reason: "herdr-resume-unsupported" };
|
|
84
98
|
const badCwd = classifyTmuxCwd(params.cwd);
|
|
85
99
|
if (badCwd)
|
|
86
100
|
return { ok: false, reason: badCwd };
|
|
87
101
|
let runtimePath;
|
|
88
102
|
try {
|
|
89
|
-
runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
|
|
103
|
+
runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME[params.backend], env);
|
|
90
104
|
}
|
|
91
105
|
catch (err) {
|
|
92
106
|
if (err instanceof LaunchPreconditionError)
|
|
@@ -132,7 +146,8 @@ export const RESUME_CALL_REJECT_HINT = {
|
|
|
132
146
|
"cwd-format-token": "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
|
|
133
147
|
"cwd-missing": "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
|
|
134
148
|
"cwd-not-directory": "the recorded cwd exists but is not a directory",
|
|
135
|
-
"
|
|
149
|
+
"herdr-resume-unsupported": "this agent runs inside herdr, and same-id resume has only a tmux rail — nothing was opened in any ambient tmux session",
|
|
150
|
+
"runtime-unresolved": "the citizen's runtime (pi or claude) is not installed on PATH",
|
|
136
151
|
"runtime-not-absolute": "the resolved runtime path is not absolute",
|
|
137
152
|
"runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
|
|
138
153
|
"runtime-missing": "nothing exists at the resolved runtime path",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Pi core-side request adapter, not a public tool or authorization boundary.
|
|
2
|
+
* The caller supplies a CERTIFIED MetaIdentity and owns authorization, self-retire refusal,
|
|
3
|
+
* target locking and record freshness. No second lock, record scan or receiver admission
|
|
4
|
+
* implementation lives here. The receiver owns generation/TUI/idle/pending validation.
|
|
5
|
+
*/
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import { controlSocketPathIn, defaultControlSocketDir } from "./control-socket-path.js";
|
|
8
|
+
import { sendRpcCommand } from "./entwurf-control-rpc.js";
|
|
9
|
+
import { SESSION_ID_RE } from "./session-id.js";
|
|
10
|
+
const RECEIVER_REFUSALS = [
|
|
11
|
+
"target-closing",
|
|
12
|
+
"target-stale",
|
|
13
|
+
"target-context-unavailable",
|
|
14
|
+
"target-non-tui",
|
|
15
|
+
"target-busy",
|
|
16
|
+
"target-pending",
|
|
17
|
+
];
|
|
18
|
+
function object(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function exactKeys(value, keys) {
|
|
22
|
+
const actual = Object.keys(value);
|
|
23
|
+
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
24
|
+
}
|
|
25
|
+
/** Closed wire combinations only. A malformed answer AFTER sending cannot prove no action.
|
|
26
|
+
* SDK request acceptance is not exit, persistence/flush, or no-further-model-activity evidence.
|
|
27
|
+
*/
|
|
28
|
+
export function parsePiRetireResponse(response) {
|
|
29
|
+
const malformed = { boundary: "unknown", reason: "malformed-response" };
|
|
30
|
+
if (!object(response) || response.type !== "response" || response.command !== "retire")
|
|
31
|
+
return malformed;
|
|
32
|
+
if (Object.keys(response).some((key) => !["type", "command", "success", "data", "error", "id"].includes(key))) {
|
|
33
|
+
return malformed;
|
|
34
|
+
}
|
|
35
|
+
if (Object.hasOwn(response, "id") && typeof response.id !== "string")
|
|
36
|
+
return malformed;
|
|
37
|
+
const data = response.data;
|
|
38
|
+
if (!object(data))
|
|
39
|
+
return malformed;
|
|
40
|
+
if (response.success === true && data.boundary === "shutdown-requested") {
|
|
41
|
+
if (!exactKeys(data, ["boundary"]) || Object.hasOwn(response, "error"))
|
|
42
|
+
return malformed;
|
|
43
|
+
return { boundary: "shutdown-requested" };
|
|
44
|
+
}
|
|
45
|
+
if (response.success !== false || !exactKeys(data, ["boundary", "reason"]) || response.error !== data.reason) {
|
|
46
|
+
return malformed;
|
|
47
|
+
}
|
|
48
|
+
if (data.boundary === "refused" && RECEIVER_REFUSALS.includes(data.reason)) {
|
|
49
|
+
return { boundary: "refused", phase: "receiver", reason: data.reason };
|
|
50
|
+
}
|
|
51
|
+
if (data.boundary === "unknown" && data.reason === "shutdown-call-failed") {
|
|
52
|
+
return { boundary: "unknown", reason: "shutdown-call-failed" };
|
|
53
|
+
}
|
|
54
|
+
return malformed;
|
|
55
|
+
}
|
|
56
|
+
export async function requestPiRetire(target, expected, options = {}) {
|
|
57
|
+
if (target.backend !== "pi") {
|
|
58
|
+
return { boundary: "refused", phase: "preflight", reason: "target-backend-unsupported" };
|
|
59
|
+
}
|
|
60
|
+
if (target.gardenId !== expected.gardenId ||
|
|
61
|
+
target.nativeSessionId !== expected.nativeSessionId ||
|
|
62
|
+
!SESSION_ID_RE.test(target.gardenId) ||
|
|
63
|
+
target.nativeSessionId.length === 0) {
|
|
64
|
+
return { boundary: "refused", phase: "preflight", reason: "target-identity-mismatch" };
|
|
65
|
+
}
|
|
66
|
+
const socketPath = controlSocketPathIn(options.controlSocketDir ?? defaultControlSocketDir(os.homedir()), target.gardenId);
|
|
67
|
+
try {
|
|
68
|
+
const { response } = await (options.sendRpc ?? sendRpcCommand)(socketPath, { type: "retire", gardenId: target.gardenId, nativeSessionId: target.nativeSessionId }, { timeout: options.timeoutMs ?? 5000 });
|
|
69
|
+
return parsePiRetireResponse(response);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// The RPC API does not expose whether bytes were written. Even connection errors
|
|
73
|
+
// are conservatively unknown here; do not infer a safe pre-send refusal or retry.
|
|
74
|
+
return { boundary: "unknown", reason: "transport-error" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|