@junghanacs/entwurf 0.39.0 → 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 +5 -3
- package/CHANGELOG.md +236 -0
- package/DELIVERY.md +34 -0
- package/README.md +11 -9
- package/VERIFY.md +11 -1
- package/docs/setup-clean-host.md +42 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
- 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/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +25 -5
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -0
- package/package.json +2 -2
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- 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/run.sh +44 -0
- package/scripts/check-gate-qualification.ts +6 -0
- 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/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/remote-pair-receive.ts +73 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-pair-send — the SENDING half of #107's one declared remote pair (rail 2a).
|
|
3
|
+
*
|
|
4
|
+
* It sits in front of the local v2 decider, at the surface. With no route declaration, or a target
|
|
5
|
+
* the declaration does not name, the local path runs unchanged. When the declaration names the
|
|
6
|
+
* target, any local trace of that gid (a record entry in any state, or anything at its control
|
|
7
|
+
* socket path) fails closed as an ambiguity before transport. Otherwise the call runs over one
|
|
8
|
+
* transport call: no synthetic record, no new backend, no new rail plan, and no retry.
|
|
9
|
+
*
|
|
10
|
+
* Sender: only the caller's authoritative envelope becomes the claim. It must be the declared local
|
|
11
|
+
* citizen; an anonymous envelope or another gid is refused before transport. The model chooses
|
|
12
|
+
* neither host nor command: both come from the operator's declaration.
|
|
13
|
+
*
|
|
14
|
+
* Remote command: ssh joins the remote words into one shell string. So the alias and the remote
|
|
15
|
+
* declaration path are held to a narrow POSIX-safe charset that needs no quoting, and the command
|
|
16
|
+
* is the fixed `entwurf remote-pair-receive --pair <path>`. There is no quoting engine here.
|
|
17
|
+
*
|
|
18
|
+
* Receipt: a result is decided only when the exit code and the one stdout JSON line agree
|
|
19
|
+
* (see {@link classifyRemoteReceipt}). Everything else is `remote-indeterminate`: the other host
|
|
20
|
+
* may or may not have enqueued. Read and answer stay unknown until a reply arrives.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { controlSocketPathIn } from "./control-socket-path.js";
|
|
25
|
+
import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.js";
|
|
26
|
+
import { inspectControlSocketPath } from "./socket-discovery.js";
|
|
27
|
+
export class RemoteRouteDeclarationError extends Error {
|
|
28
|
+
}
|
|
29
|
+
const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
30
|
+
const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
|
|
31
|
+
const CONNECT_TIMEOUT_SECONDS = 10;
|
|
32
|
+
export const REMOTE_SEND_TIMEOUT_MS = 30_000;
|
|
33
|
+
const PAIR_REJECT_REASONS = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
|
|
34
|
+
export function parseRemoteRouteDeclaration(text) {
|
|
35
|
+
const what = "remote route declaration";
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
42
|
+
}
|
|
43
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
44
|
+
throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
|
|
45
|
+
}
|
|
46
|
+
const obj = raw;
|
|
47
|
+
const want = ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"];
|
|
48
|
+
const got = Object.keys(obj).sort();
|
|
49
|
+
if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
|
|
50
|
+
throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
|
|
51
|
+
}
|
|
52
|
+
const { ssh, remotePairFile } = obj;
|
|
53
|
+
if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
|
|
54
|
+
throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof remotePairFile !== "string" ||
|
|
57
|
+
!REMOTE_PATH.test(remotePairFile) ||
|
|
58
|
+
remotePairFile.split("/").includes("..")) {
|
|
59
|
+
throw new RemoteRouteDeclarationError(`${what}: "remotePairFile" must be an absolute path matching ${REMOTE_PATH} with no ".." segment`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
remoteGardenId: requireGardenId(obj.remoteGardenId),
|
|
63
|
+
ssh,
|
|
64
|
+
localGardenId: requireGardenId(obj.localGardenId),
|
|
65
|
+
remotePairFile,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** The one argv the transport runs. Every word is inside the no-quoting charset above. */
|
|
69
|
+
export function remoteReceiveArgv(route) {
|
|
70
|
+
return [
|
|
71
|
+
"ssh",
|
|
72
|
+
"-o",
|
|
73
|
+
"BatchMode=yes",
|
|
74
|
+
"-o",
|
|
75
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
|
|
76
|
+
route.ssh,
|
|
77
|
+
"entwurf",
|
|
78
|
+
"remote-pair-receive",
|
|
79
|
+
"--pair",
|
|
80
|
+
route.remotePairFile,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
function tail(s) {
|
|
84
|
+
return s.length > 400 ? s.slice(-400) : s;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Decide only on agreement. 0 + dispatched/!isError = accepted; 3 + rejected(pair reason) = pair
|
|
88
|
+
* refused; 4 + dispatched/isError = the other host reported an error while processing (its own text
|
|
89
|
+
* says what is known, e.g. `retry-safe: false` after a possible write; this line infers neither
|
|
90
|
+
* delivery nor non-delivery); 2 + invalid = it refused our
|
|
91
|
+
* input. Any other exit, a signal, a timeout, no line, more than one line, non-JSON, or a JSON
|
|
92
|
+
* kind/field that disagrees with the exit is indeterminate.
|
|
93
|
+
*/
|
|
94
|
+
export function classifyRemoteReceipt(r) {
|
|
95
|
+
const indeterminate = (why) => ({
|
|
96
|
+
kind: "remote-indeterminate",
|
|
97
|
+
why,
|
|
98
|
+
exitCode: r.exitCode,
|
|
99
|
+
stderrTail: tail(r.stderr),
|
|
100
|
+
});
|
|
101
|
+
if (r.timedOut)
|
|
102
|
+
return indeterminate("timed out");
|
|
103
|
+
if (r.signal !== null)
|
|
104
|
+
return indeterminate(`ended by signal ${r.signal}`);
|
|
105
|
+
if (r.exitCode === null) {
|
|
106
|
+
return indeterminate(r.stderr.startsWith("spawn failed")
|
|
107
|
+
? "the transport did not start (spawn failed)"
|
|
108
|
+
: "did not exit (no exit code)");
|
|
109
|
+
}
|
|
110
|
+
if (![0, 2, 3, 4].includes(r.exitCode))
|
|
111
|
+
return indeterminate(`exit ${r.exitCode}`);
|
|
112
|
+
const nl = r.stdout.indexOf("\n");
|
|
113
|
+
if (nl === -1 || nl !== r.stdout.length - 1)
|
|
114
|
+
return indeterminate("stdout is not exactly one line");
|
|
115
|
+
let j;
|
|
116
|
+
try {
|
|
117
|
+
j = JSON.parse(r.stdout);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return indeterminate("stdout is not JSON");
|
|
121
|
+
}
|
|
122
|
+
if (j === null || typeof j !== "object" || Array.isArray(j))
|
|
123
|
+
return indeterminate("stdout is not a JSON object");
|
|
124
|
+
const o = j;
|
|
125
|
+
const keys = Object.keys(o).sort().join(",");
|
|
126
|
+
if (r.exitCode === 0 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === false) {
|
|
127
|
+
if (typeof o.text === "string")
|
|
128
|
+
return { kind: "remote-accepted", text: o.text };
|
|
129
|
+
}
|
|
130
|
+
if (r.exitCode === 4 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === true) {
|
|
131
|
+
if (typeof o.text === "string")
|
|
132
|
+
return { kind: "remote-reported-error", text: o.text };
|
|
133
|
+
}
|
|
134
|
+
if (r.exitCode === 3 && keys === "detail,kind,reason" && o.kind === "rejected") {
|
|
135
|
+
if (typeof o.reason === "string" && PAIR_REJECT_REASONS.includes(o.reason) && typeof o.detail === "string") {
|
|
136
|
+
return { kind: "remote-pair-refused", reason: o.reason, detail: o.detail };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (r.exitCode === 2 && keys === "detail,kind" && o.kind === "invalid" && typeof o.detail === "string") {
|
|
140
|
+
return { kind: "remote-invalid", detail: o.detail };
|
|
141
|
+
}
|
|
142
|
+
return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
|
|
143
|
+
}
|
|
144
|
+
function render(route, o) {
|
|
145
|
+
const via = `via ${route.ssh}`;
|
|
146
|
+
switch (o.kind) {
|
|
147
|
+
case "remote-accepted":
|
|
148
|
+
return {
|
|
149
|
+
isError: false,
|
|
150
|
+
text: `entwurf_v2 remote ${via} → accepted by the other host (its fact; read/answer unknown until a reply arrives; paths below are on the other host)\n${o.text}`,
|
|
151
|
+
};
|
|
152
|
+
case "remote-reported-error":
|
|
153
|
+
// The other host's dispatch returned isError. That can follow a write (an enqueue whose
|
|
154
|
+
// stamp or signal then threw is `retry-safe: false`), so this header claims neither
|
|
155
|
+
// non-delivery nor safety to resend: the other host's own text below is the fact.
|
|
156
|
+
return {
|
|
157
|
+
isError: true,
|
|
158
|
+
text: `entwurf_v2 remote ${via} → the other host reported an error while processing; read its receipt below, delivery is not inferred from this line; not retried (paths below are on the other host)\n${o.text}`,
|
|
159
|
+
};
|
|
160
|
+
case "remote-pair-refused":
|
|
161
|
+
return {
|
|
162
|
+
isError: true,
|
|
163
|
+
text: `entwurf_v2 remote ${via} → refused by the other host's pair: ${o.reason} (${o.detail})`,
|
|
164
|
+
};
|
|
165
|
+
case "remote-invalid":
|
|
166
|
+
return { isError: true, text: `entwurf_v2 remote ${via} → the other host refused the input: ${o.detail}` };
|
|
167
|
+
case "remote-indeterminate":
|
|
168
|
+
return {
|
|
169
|
+
isError: true,
|
|
170
|
+
text: `entwurf_v2 remote ${via} → indeterminate: ${o.why}. The other host may or may not have enqueued; ` +
|
|
171
|
+
`not retried.${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function refuse(text) {
|
|
176
|
+
return { isError: true, text: `entwurf_v2 rejected: ${text}` };
|
|
177
|
+
}
|
|
178
|
+
export async function runEntwurfV2WithRemoteRoute(params, opts) {
|
|
179
|
+
const route = opts.route;
|
|
180
|
+
if (route === null || params.target !== route.remoteGardenId) {
|
|
181
|
+
return opts.runLocal(params, { senderProvider: opts.senderProvider });
|
|
182
|
+
}
|
|
183
|
+
if (await opts.localTrace(route.remoteGardenId)) {
|
|
184
|
+
return refuse(`remote-route-local-conflict: ${route.remoteGardenId} is declared remote ${`via ${route.ssh}`} but this host also has a local trace of it; not choosing`);
|
|
185
|
+
}
|
|
186
|
+
const sender = opts.senderProvider();
|
|
187
|
+
if (!sender || sender.origin === "external-mcp" || sender.sessionId !== route.localGardenId) {
|
|
188
|
+
return refuse(`remote-sender-not-declared: only ${route.localGardenId} may send over this route (sender: ${sender ? sender.sessionId : "none"})`);
|
|
189
|
+
}
|
|
190
|
+
const claim = {
|
|
191
|
+
from: sender.sessionId,
|
|
192
|
+
fromAgentId: sender.agentId,
|
|
193
|
+
fromCwd: sender.cwd,
|
|
194
|
+
target: route.remoteGardenId,
|
|
195
|
+
message: params.message,
|
|
196
|
+
wantsReply: params.wants_reply ?? false,
|
|
197
|
+
};
|
|
198
|
+
const result = await opts.transport.run(remoteReceiveArgv(route), JSON.stringify(claim), opts.timeoutMs ?? REMOTE_SEND_TIMEOUT_MS);
|
|
199
|
+
return render(route, classifyRemoteReceipt(result));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The production local-trace fact: the store's own record-entry inspection (absent vs anything
|
|
203
|
+
* else — readable, unreadable, symlinked or drifted all count) and the control-socket path
|
|
204
|
+
* inspection, in EVERY socket dir the caller names. The bridge's `ENTWURF_DIR` can differ from the
|
|
205
|
+
* decider's canonical dir, so both are passed and a trace in either one means this host already
|
|
206
|
+
* answers to the gid.
|
|
207
|
+
*/
|
|
208
|
+
export function makeLocalTraceFact(dirs) {
|
|
209
|
+
const socketDirs = [...new Set(dirs.controlSocketDirs.map((d) => path.resolve(d)))];
|
|
210
|
+
return async (gardenId) => {
|
|
211
|
+
if (metaRecordExistsByGardenId(gardenId, dirs.sessionsDir))
|
|
212
|
+
return true;
|
|
213
|
+
for (const dir of socketDirs) {
|
|
214
|
+
const inspection = await inspectControlSocketPath(controlSocketPathIn(dir, gardenId));
|
|
215
|
+
if (inspection.kind !== "absent")
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** The operator's opt-in file name. One per host; it declares that host's one remote pair. */
|
|
222
|
+
export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
|
|
223
|
+
/**
|
|
224
|
+
* Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
|
|
225
|
+
* `ENTWURF_META_SESSIONS_DIR` override relocates the store (then the file follows the store it
|
|
226
|
+
* pairs). Read-only reuse of the existing root resolution; nothing here writes.
|
|
227
|
+
*/
|
|
228
|
+
export function remotePairOptInPath() {
|
|
229
|
+
return path.join(path.dirname(defaultMetaSessionsDir()), REMOTE_PAIR_OPT_IN_FILE);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Read the opt-in file once, at the moment of use (no cache). Absent is the ONLY "off". A
|
|
233
|
+
* symlink, a non-regular entry, an unreadable file or an invalid declaration throws: the operator
|
|
234
|
+
* opted in, so a broken opt-in is refused rather than read as off.
|
|
235
|
+
*
|
|
236
|
+
* The bridge this declares is the operator's to build and secure (for example a private network
|
|
237
|
+
* such as Tailscale, plus SSH authentication and permissions). Entwurf only walks the declared
|
|
238
|
+
* route: it installs, configures, discovers and verifies none of it, and never retries.
|
|
239
|
+
*/
|
|
240
|
+
export function readRemotePairOptIn(file) {
|
|
241
|
+
let st;
|
|
242
|
+
try {
|
|
243
|
+
st = fs.lstatSync(file);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (err.code === "ENOENT")
|
|
247
|
+
return null;
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
if (!st.isFile()) {
|
|
251
|
+
throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
return parseRemoteRouteDeclaration(fs.readFileSync(file, "utf8"));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-pair-transport — the production process adapter behind #107's `RemoteTransport` (rail 2c).
|
|
3
|
+
*
|
|
4
|
+
* One child per send: `spawn(argv[0], argv.slice(1))` with no shell, stdin written once and closed,
|
|
5
|
+
* stdout and stderr captured separately. On the bound it signals ITS OWN child only (TERM, then KILL
|
|
6
|
+
* after a short grace); if the pipes are still held after one more grace, it closes its own read ends
|
|
7
|
+
* and settles as a timeout rather than waiting for a close that may never come. It is not a process manager: it tracks no
|
|
8
|
+
* other process, scans nothing, and never retries. Classification belongs to
|
|
9
|
+
* `classifyRemoteReceipt`; this module only reports what the child did.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
const CAPTURE_CAP = 64 * 1024;
|
|
13
|
+
const KILL_GRACE_MS = 2000;
|
|
14
|
+
const FINAL_GRACE_MS = 2000;
|
|
15
|
+
function capped(chunks, size, chunk) {
|
|
16
|
+
if (size.n >= CAPTURE_CAP)
|
|
17
|
+
return;
|
|
18
|
+
const room = CAPTURE_CAP - size.n;
|
|
19
|
+
const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
20
|
+
chunks.push(piece);
|
|
21
|
+
size.n += piece.length;
|
|
22
|
+
}
|
|
23
|
+
export const processTransport = {
|
|
24
|
+
run(argv, stdin, timeoutMs) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const out = [];
|
|
27
|
+
const err = [];
|
|
28
|
+
const outN = { n: 0 };
|
|
29
|
+
const errN = { n: 0 };
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
let spawnError = null;
|
|
32
|
+
let settled = false;
|
|
33
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
|
|
34
|
+
let killTimer;
|
|
35
|
+
let finalTimer;
|
|
36
|
+
const settle = (code, signal) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(bound);
|
|
41
|
+
if (killTimer)
|
|
42
|
+
clearTimeout(killTimer);
|
|
43
|
+
if (finalTimer)
|
|
44
|
+
clearTimeout(finalTimer);
|
|
45
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
46
|
+
resolve({
|
|
47
|
+
exitCode: spawnError ? null : code,
|
|
48
|
+
signal: spawnError ? null : signal,
|
|
49
|
+
timedOut,
|
|
50
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
51
|
+
stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const bound = setTimeout(() => {
|
|
55
|
+
timedOut = true;
|
|
56
|
+
child.kill("SIGTERM");
|
|
57
|
+
killTimer = setTimeout(() => {
|
|
58
|
+
child.kill("SIGKILL");
|
|
59
|
+
// Something else may still hold our stdout/stderr pipes (an inherited descendant,
|
|
60
|
+
// an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
|
|
61
|
+
// ends and settle as a timeout, which the receipt reads as indeterminate.
|
|
62
|
+
finalTimer = setTimeout(() => {
|
|
63
|
+
child.stdout.destroy();
|
|
64
|
+
child.stderr.destroy();
|
|
65
|
+
settle(child.exitCode, child.signalCode);
|
|
66
|
+
}, FINAL_GRACE_MS);
|
|
67
|
+
}, KILL_GRACE_MS);
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
child.stdout.on("data", (c) => capped(out, outN, c));
|
|
70
|
+
child.stderr.on("data", (c) => capped(err, errN, c));
|
|
71
|
+
child.on("error", (e) => {
|
|
72
|
+
spawnError = e.message;
|
|
73
|
+
});
|
|
74
|
+
// A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
|
|
75
|
+
child.stdin.on("error", () => { });
|
|
76
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
77
|
+
child.stdin.end(stdin);
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* remote-pair-receive — `entwurf remote-pair-receive --pair <absolute file>` (#107 rail 1).
|
|
4
|
+
*
|
|
5
|
+
* The receiving host's entry for one declared remote pair. The sending host runs it over one
|
|
6
|
+
* SSH call and writes one JSON claim to stdin. This entry reads the operator-named pair
|
|
7
|
+
* declaration once, applies the pair scope check, and hands a matching claim to the existing
|
|
8
|
+
* v2 dispatch. The scope and trust model are in pi-extensions/lib/remote-pair-receive.ts.
|
|
9
|
+
*
|
|
10
|
+
* Scope: `--pair` must name THIS host's own opt-in file (`remotePairOptInPath()`), absolute and
|
|
11
|
+
* normalized-equal; any other path — even a valid declaration — is exit 2. That one file is the pair
|
|
12
|
+
* this host accepts and, because it names the sender's host as its route, the declared route back.
|
|
13
|
+
* The sender's declaration carries the path only so the argv shape is explicit; it selects nothing.
|
|
14
|
+
*
|
|
15
|
+
* EXIT CONTRACT (stdout carries a JSON receipt line only where named):
|
|
16
|
+
* 0 dispatched, the dispatch did not report an error — receipt on stdout
|
|
17
|
+
* 4 dispatched, the dispatch reported an error (its text says what is known, e.g. a
|
|
18
|
+
* rejection or `retry-safe: false` after a possible write) — receipt
|
|
19
|
+
* 3 refused by the pair scope check, no dispatch ran — receipt
|
|
20
|
+
* 2 --pair is not this host's opt-in, the opt-in is absent/broken, or the claim is invalid; no
|
|
21
|
+
* dispatch ran — `{kind:"invalid"}` receipt;
|
|
22
|
+
* wrong argv is ALSO 2 but prints usage on stderr only, with no receipt
|
|
23
|
+
* 1 an uncaught dispatch/store throw (crash, don't warn) — no receipt; stderr carries the
|
|
24
|
+
* error. The sending host reads that as indeterminate.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { runAndRenderEntwurfV2FromSurface } from "../pi-extensions/lib/entwurf-v2-surface.js";
|
|
29
|
+
import { parseRemoteClaim, receiveRemoteClaim, } from "../pi-extensions/lib/remote-pair-receive.js";
|
|
30
|
+
import { readRemotePairOptIn, remotePairOptInPath } from "../pi-extensions/lib/remote-pair-send.js";
|
|
31
|
+
function emit(receipt, code) {
|
|
32
|
+
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
|
33
|
+
process.exit(code);
|
|
34
|
+
}
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
if (args.length !== 2 || args[0] !== "--pair") {
|
|
37
|
+
console.error("usage: entwurf remote-pair-receive --pair <this host's opt-in file> (claim JSON on stdin)");
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const pairFile = args[1];
|
|
41
|
+
let decl;
|
|
42
|
+
let claim;
|
|
43
|
+
try {
|
|
44
|
+
// The scope is THIS host's own opt-in, never a file the sender picks: `--pair` must name it
|
|
45
|
+
// exactly (absolute, normalized), and that one file is read for this receive.
|
|
46
|
+
const own = remotePairOptInPath();
|
|
47
|
+
if (!path.isAbsolute(pairFile) || path.normalize(pairFile) !== own) {
|
|
48
|
+
throw new Error(`--pair must be this host's opt-in file ${own} (got ${pairFile})`);
|
|
49
|
+
}
|
|
50
|
+
const route = readRemotePairOptIn(own);
|
|
51
|
+
if (route === null)
|
|
52
|
+
throw new Error(`this host has no remote pair opt-in (${own} is absent)`);
|
|
53
|
+
decl = { remoteGardenId: route.remoteGardenId, ssh: route.ssh, localGardenId: route.localGardenId };
|
|
54
|
+
claim = parseRemoteClaim(readFileSync(0, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
emit({ kind: "invalid", detail: err instanceof Error ? err.message : String(err) }, 2);
|
|
58
|
+
}
|
|
59
|
+
const receipt = await receiveRemoteClaim(claim, decl, {
|
|
60
|
+
dispatch: (params, sender) => runAndRenderEntwurfV2FromSurface(params, { senderProvider: () => sender }),
|
|
61
|
+
// The scope file IS this host's own opt-in, and it names the sender's host as its route: a
|
|
62
|
+
// matching claim always has a declared route back. Declared, not probed.
|
|
63
|
+
replyRoute: "declared",
|
|
64
|
+
});
|
|
65
|
+
emit(receipt, receipt.kind === "rejected" ? 3 : receipt.isError ? 4 : 0);
|
|
@@ -108,6 +108,14 @@ 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";
|
|
111
119
|
import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.ts";
|
|
112
120
|
|
|
113
121
|
const HOME = os.homedir();
|
|
@@ -520,12 +528,24 @@ server.tool(
|
|
|
520
528
|
// Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
|
|
521
529
|
// + the mailbox body sender share one envelope. No replyability gate (see above).
|
|
522
530
|
const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
|
|
523
|
-
|
|
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(
|
|
524
534
|
{ target, intent, message, mode, wants_reply },
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
},
|
|
529
549
|
);
|
|
530
550
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
531
551
|
} catch (err) {
|
|
@@ -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",
|
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": {
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
"check:full": "bash scripts/check-elapsed.sh check:full check:core check:hermetic check:package",
|
|
196
196
|
"check:core": "pnpm run check:toolchain && pnpm run check:vitest && pnpm run check:contracts",
|
|
197
197
|
"check:toolchain": "pnpm lint && pnpm typecheck",
|
|
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",
|
|
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
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",
|
|
200
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",
|
|
201
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"
|
|
@@ -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` +
|