@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,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
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-pair-send — the SENDING half of #107's one declared remote pair (rail 2a).
|
|
3
|
+
*
|
|
4
|
+
* It sits in front of the local v2 decider, at the surface. With no route declaration, or a target
|
|
5
|
+
* the declaration does not name, the local path runs unchanged. When the declaration names the
|
|
6
|
+
* target, any local trace of that gid (a record entry in any state, or anything at its control
|
|
7
|
+
* socket path) fails closed as an ambiguity before transport. Otherwise the call runs over one
|
|
8
|
+
* transport call: no synthetic record, no new backend, no new rail plan, and no retry.
|
|
9
|
+
*
|
|
10
|
+
* Sender: only the caller's authoritative envelope becomes the claim. It must be the declared local
|
|
11
|
+
* citizen; an anonymous envelope or another gid is refused before transport. The model chooses
|
|
12
|
+
* neither host nor command: both come from the operator's declaration.
|
|
13
|
+
*
|
|
14
|
+
* Remote command: ssh joins the remote words into one shell string. So the alias and the remote
|
|
15
|
+
* declaration path are held to a narrow POSIX-safe charset that needs no quoting, and the command
|
|
16
|
+
* is the fixed `entwurf remote-pair-receive --pair <path>`. There is no quoting engine here.
|
|
17
|
+
*
|
|
18
|
+
* Receipt: a result is decided only when the exit code and the one stdout JSON line agree
|
|
19
|
+
* (see {@link classifyRemoteReceipt}). Everything else is `remote-indeterminate`: the other host
|
|
20
|
+
* may or may not have enqueued. Read and answer stay unknown until a reply arrives.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { controlSocketPathIn } from "./control-socket-path.js";
|
|
26
|
+
import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
|
|
27
|
+
import type { EntwurfV2SurfaceRendered, SurfaceEntwurfV2Params } from "./entwurf-v2-surface.ts";
|
|
28
|
+
import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.ts";
|
|
29
|
+
import type { RemoteClaim, RemotePairRejectReason } from "./remote-pair-receive.ts";
|
|
30
|
+
import { inspectControlSocketPath } from "./socket-discovery.ts";
|
|
31
|
+
|
|
32
|
+
/** The SENDING host's declaration of its one remote pair. */
|
|
33
|
+
export interface RemoteRouteDeclaration {
|
|
34
|
+
/** The target's garden id on the other host. */
|
|
35
|
+
remoteGardenId: string;
|
|
36
|
+
/** SSH alias for the other host (route label; never an identity). */
|
|
37
|
+
ssh: string;
|
|
38
|
+
/** The garden id on THIS host that may send to it. */
|
|
39
|
+
localGardenId: string;
|
|
40
|
+
/** Absolute path of the other host's pair declaration, passed to its receive entry. */
|
|
41
|
+
remotePairFile: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RemoteTransportResult {
|
|
45
|
+
exitCode: number | null;
|
|
46
|
+
signal: string | null;
|
|
47
|
+
timedOut: boolean;
|
|
48
|
+
stdout: string;
|
|
49
|
+
stderr: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RemoteTransport {
|
|
53
|
+
run(argv: readonly string[], stdin: string, timeoutMs: number): Promise<RemoteTransportResult>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type RemoteSendOutcome =
|
|
57
|
+
| { kind: "remote-accepted"; text: string }
|
|
58
|
+
| { kind: "remote-pair-refused"; reason: RemotePairRejectReason; detail: string }
|
|
59
|
+
| { kind: "remote-reported-error"; text: string }
|
|
60
|
+
| { kind: "remote-invalid"; detail: string }
|
|
61
|
+
| { kind: "remote-indeterminate"; why: string; exitCode: number | null; stderrTail: string };
|
|
62
|
+
|
|
63
|
+
export class RemoteRouteDeclarationError extends Error {}
|
|
64
|
+
|
|
65
|
+
const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
66
|
+
const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
|
|
67
|
+
const CONNECT_TIMEOUT_SECONDS = 10;
|
|
68
|
+
export const REMOTE_SEND_TIMEOUT_MS = 30_000;
|
|
69
|
+
const PAIR_REJECT_REASONS: readonly string[] = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
|
|
70
|
+
|
|
71
|
+
export function parseRemoteRouteDeclaration(text: string): RemoteRouteDeclaration {
|
|
72
|
+
const what = "remote route declaration";
|
|
73
|
+
let raw: unknown;
|
|
74
|
+
try {
|
|
75
|
+
raw = JSON.parse(text);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
78
|
+
}
|
|
79
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
80
|
+
throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
|
|
81
|
+
}
|
|
82
|
+
const obj = raw as Record<string, unknown>;
|
|
83
|
+
const want = ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"];
|
|
84
|
+
const got = Object.keys(obj).sort();
|
|
85
|
+
if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
|
|
86
|
+
throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
|
|
87
|
+
}
|
|
88
|
+
const { ssh, remotePairFile } = obj;
|
|
89
|
+
if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
|
|
90
|
+
throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
typeof remotePairFile !== "string" ||
|
|
94
|
+
!REMOTE_PATH.test(remotePairFile) ||
|
|
95
|
+
remotePairFile.split("/").includes("..")
|
|
96
|
+
) {
|
|
97
|
+
throw new RemoteRouteDeclarationError(
|
|
98
|
+
`${what}: "remotePairFile" must be an absolute path matching ${REMOTE_PATH} with no ".." segment`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
remoteGardenId: requireGardenId(obj.remoteGardenId),
|
|
103
|
+
ssh,
|
|
104
|
+
localGardenId: requireGardenId(obj.localGardenId),
|
|
105
|
+
remotePairFile,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The one argv the transport runs. Every word is inside the no-quoting charset above. */
|
|
110
|
+
export function remoteReceiveArgv(route: RemoteRouteDeclaration): string[] {
|
|
111
|
+
return [
|
|
112
|
+
"ssh",
|
|
113
|
+
"-o",
|
|
114
|
+
"BatchMode=yes",
|
|
115
|
+
"-o",
|
|
116
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
|
|
117
|
+
route.ssh,
|
|
118
|
+
"entwurf",
|
|
119
|
+
"remote-pair-receive",
|
|
120
|
+
"--pair",
|
|
121
|
+
route.remotePairFile,
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tail(s: string): string {
|
|
126
|
+
return s.length > 400 ? s.slice(-400) : s;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decide only on agreement. 0 + dispatched/!isError = accepted; 3 + rejected(pair reason) = pair
|
|
131
|
+
* refused; 4 + dispatched/isError = the other host reported an error while processing (its own text
|
|
132
|
+
* says what is known, e.g. `retry-safe: false` after a possible write; this line infers neither
|
|
133
|
+
* delivery nor non-delivery); 2 + invalid = it refused our
|
|
134
|
+
* input. Any other exit, a signal, a timeout, no line, more than one line, non-JSON, or a JSON
|
|
135
|
+
* kind/field that disagrees with the exit is indeterminate.
|
|
136
|
+
*/
|
|
137
|
+
export function classifyRemoteReceipt(r: RemoteTransportResult): RemoteSendOutcome {
|
|
138
|
+
const indeterminate = (why: string): RemoteSendOutcome => ({
|
|
139
|
+
kind: "remote-indeterminate",
|
|
140
|
+
why,
|
|
141
|
+
exitCode: r.exitCode,
|
|
142
|
+
stderrTail: tail(r.stderr),
|
|
143
|
+
});
|
|
144
|
+
if (r.timedOut) return indeterminate("timed out");
|
|
145
|
+
if (r.signal !== null) return indeterminate(`ended by signal ${r.signal}`);
|
|
146
|
+
if (r.exitCode === null) {
|
|
147
|
+
return indeterminate(
|
|
148
|
+
r.stderr.startsWith("spawn failed")
|
|
149
|
+
? "the transport did not start (spawn failed)"
|
|
150
|
+
: "did not exit (no exit code)",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (![0, 2, 3, 4].includes(r.exitCode)) return indeterminate(`exit ${r.exitCode}`);
|
|
154
|
+
const nl = r.stdout.indexOf("\n");
|
|
155
|
+
if (nl === -1 || nl !== r.stdout.length - 1) return indeterminate("stdout is not exactly one line");
|
|
156
|
+
let j: unknown;
|
|
157
|
+
try {
|
|
158
|
+
j = JSON.parse(r.stdout);
|
|
159
|
+
} catch {
|
|
160
|
+
return indeterminate("stdout is not JSON");
|
|
161
|
+
}
|
|
162
|
+
if (j === null || typeof j !== "object" || Array.isArray(j)) return indeterminate("stdout is not a JSON object");
|
|
163
|
+
const o = j as Record<string, unknown>;
|
|
164
|
+
const keys = Object.keys(o).sort().join(",");
|
|
165
|
+
if (r.exitCode === 0 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === false) {
|
|
166
|
+
if (typeof o.text === "string") return { kind: "remote-accepted", text: o.text };
|
|
167
|
+
}
|
|
168
|
+
if (r.exitCode === 4 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === true) {
|
|
169
|
+
if (typeof o.text === "string") return { kind: "remote-reported-error", text: o.text };
|
|
170
|
+
}
|
|
171
|
+
if (r.exitCode === 3 && keys === "detail,kind,reason" && o.kind === "rejected") {
|
|
172
|
+
if (typeof o.reason === "string" && PAIR_REJECT_REASONS.includes(o.reason) && typeof o.detail === "string") {
|
|
173
|
+
return { kind: "remote-pair-refused", reason: o.reason as RemotePairRejectReason, detail: o.detail };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (r.exitCode === 2 && keys === "detail,kind" && o.kind === "invalid" && typeof o.detail === "string") {
|
|
177
|
+
return { kind: "remote-invalid", detail: o.detail };
|
|
178
|
+
}
|
|
179
|
+
return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function render(route: RemoteRouteDeclaration, o: RemoteSendOutcome): EntwurfV2SurfaceRendered {
|
|
183
|
+
const via = `via ${route.ssh}`;
|
|
184
|
+
switch (o.kind) {
|
|
185
|
+
case "remote-accepted":
|
|
186
|
+
return {
|
|
187
|
+
isError: false,
|
|
188
|
+
text: `entwurf_v2 remote ${via} → accepted by the other host (its fact; read/answer unknown until a reply arrives; paths below are on the other host)\n${o.text}`,
|
|
189
|
+
};
|
|
190
|
+
case "remote-reported-error":
|
|
191
|
+
// The other host's dispatch returned isError. That can follow a write (an enqueue whose
|
|
192
|
+
// stamp or signal then threw is `retry-safe: false`), so this header claims neither
|
|
193
|
+
// non-delivery nor safety to resend: the other host's own text below is the fact.
|
|
194
|
+
return {
|
|
195
|
+
isError: true,
|
|
196
|
+
text: `entwurf_v2 remote ${via} → the other host reported an error while processing; read its receipt below, delivery is not inferred from this line; not retried (paths below are on the other host)\n${o.text}`,
|
|
197
|
+
};
|
|
198
|
+
case "remote-pair-refused":
|
|
199
|
+
return {
|
|
200
|
+
isError: true,
|
|
201
|
+
text: `entwurf_v2 remote ${via} → refused by the other host's pair: ${o.reason} (${o.detail})`,
|
|
202
|
+
};
|
|
203
|
+
case "remote-invalid":
|
|
204
|
+
return { isError: true, text: `entwurf_v2 remote ${via} → the other host refused the input: ${o.detail}` };
|
|
205
|
+
case "remote-indeterminate":
|
|
206
|
+
return {
|
|
207
|
+
isError: true,
|
|
208
|
+
text:
|
|
209
|
+
`entwurf_v2 remote ${via} → indeterminate: ${o.why}. The other host may or may not have enqueued; ` +
|
|
210
|
+
`not retried.${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function refuse(text: string): EntwurfV2SurfaceRendered {
|
|
216
|
+
return { isError: true, text: `entwurf_v2 rejected: ${text}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface RemoteRouteOptions {
|
|
220
|
+
senderProvider: () => SenderEnvelope | undefined;
|
|
221
|
+
/** The declaration, already read; null = no remote pair on this host. */
|
|
222
|
+
route: RemoteRouteDeclaration | null;
|
|
223
|
+
/** Any local trace of this gid: a record entry in any state, or anything at its socket path. */
|
|
224
|
+
localTrace: (gardenId: string) => Promise<boolean>;
|
|
225
|
+
transport: RemoteTransport;
|
|
226
|
+
/** The existing local surface, run unchanged when the route does not apply. */
|
|
227
|
+
runLocal: (
|
|
228
|
+
params: SurfaceEntwurfV2Params,
|
|
229
|
+
opts: { senderProvider: () => SenderEnvelope | undefined },
|
|
230
|
+
) => Promise<EntwurfV2SurfaceRendered>;
|
|
231
|
+
timeoutMs?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function runEntwurfV2WithRemoteRoute(
|
|
235
|
+
params: SurfaceEntwurfV2Params,
|
|
236
|
+
opts: RemoteRouteOptions,
|
|
237
|
+
): Promise<EntwurfV2SurfaceRendered> {
|
|
238
|
+
const route = opts.route;
|
|
239
|
+
if (route === null || params.target !== route.remoteGardenId) {
|
|
240
|
+
return opts.runLocal(params, { senderProvider: opts.senderProvider });
|
|
241
|
+
}
|
|
242
|
+
if (await opts.localTrace(route.remoteGardenId)) {
|
|
243
|
+
return refuse(
|
|
244
|
+
`remote-route-local-conflict: ${route.remoteGardenId} is declared remote ${`via ${route.ssh}`} but this host also has a local trace of it; not choosing`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const sender = opts.senderProvider();
|
|
248
|
+
if (!sender || sender.origin === "external-mcp" || sender.sessionId !== route.localGardenId) {
|
|
249
|
+
return refuse(
|
|
250
|
+
`remote-sender-not-declared: only ${route.localGardenId} may send over this route (sender: ${sender ? sender.sessionId : "none"})`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const claim: RemoteClaim = {
|
|
254
|
+
from: sender.sessionId,
|
|
255
|
+
fromAgentId: sender.agentId,
|
|
256
|
+
fromCwd: sender.cwd,
|
|
257
|
+
target: route.remoteGardenId,
|
|
258
|
+
message: params.message,
|
|
259
|
+
wantsReply: params.wants_reply ?? false,
|
|
260
|
+
};
|
|
261
|
+
const result = await opts.transport.run(
|
|
262
|
+
remoteReceiveArgv(route),
|
|
263
|
+
JSON.stringify(claim),
|
|
264
|
+
opts.timeoutMs ?? REMOTE_SEND_TIMEOUT_MS,
|
|
265
|
+
);
|
|
266
|
+
return render(route, classifyRemoteReceipt(result));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The production local-trace fact: the store's own record-entry inspection (absent vs anything
|
|
271
|
+
* else — readable, unreadable, symlinked or drifted all count) and the control-socket path
|
|
272
|
+
* inspection, in EVERY socket dir the caller names. The bridge's `ENTWURF_DIR` can differ from the
|
|
273
|
+
* decider's canonical dir, so both are passed and a trace in either one means this host already
|
|
274
|
+
* answers to the gid.
|
|
275
|
+
*/
|
|
276
|
+
export function makeLocalTraceFact(dirs: { sessionsDir: string; controlSocketDirs: readonly string[] }) {
|
|
277
|
+
const socketDirs = [...new Set(dirs.controlSocketDirs.map((d) => path.resolve(d)))];
|
|
278
|
+
return async (gardenId: string): Promise<boolean> => {
|
|
279
|
+
if (metaRecordExistsByGardenId(gardenId, dirs.sessionsDir)) return true;
|
|
280
|
+
for (const dir of socketDirs) {
|
|
281
|
+
const inspection = await inspectControlSocketPath(controlSocketPathIn(dir, gardenId));
|
|
282
|
+
if (inspection.kind !== "absent") return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The operator's opt-in file name. One per host; it declares that host's one remote pair. */
|
|
289
|
+
export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
|
|
293
|
+
* `ENTWURF_META_SESSIONS_DIR` override relocates the store (then the file follows the store it
|
|
294
|
+
* pairs). Read-only reuse of the existing root resolution; nothing here writes.
|
|
295
|
+
*/
|
|
296
|
+
export function remotePairOptInPath(): string {
|
|
297
|
+
return path.join(path.dirname(defaultMetaSessionsDir()), REMOTE_PAIR_OPT_IN_FILE);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read the opt-in file once, at the moment of use (no cache). Absent is the ONLY "off". A
|
|
302
|
+
* symlink, a non-regular entry, an unreadable file or an invalid declaration throws: the operator
|
|
303
|
+
* opted in, so a broken opt-in is refused rather than read as off.
|
|
304
|
+
*
|
|
305
|
+
* The bridge this declares is the operator's to build and secure (for example a private network
|
|
306
|
+
* such as Tailscale, plus SSH authentication and permissions). Entwurf only walks the declared
|
|
307
|
+
* route: it installs, configures, discovers and verifies none of it, and never retries.
|
|
308
|
+
*/
|
|
309
|
+
export function readRemotePairOptIn(file: string): RemoteRouteDeclaration | null {
|
|
310
|
+
let st: fs.Stats;
|
|
311
|
+
try {
|
|
312
|
+
st = fs.lstatSync(file);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
if (!st.isFile()) {
|
|
318
|
+
throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return parseRemoteRouteDeclaration(fs.readFileSync(file, "utf8"));
|
|
322
|
+
} catch (err) {
|
|
323
|
+
throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-pair-transport — the production process adapter behind #107's `RemoteTransport` (rail 2c).
|
|
3
|
+
*
|
|
4
|
+
* One child per send: `spawn(argv[0], argv.slice(1))` with no shell, stdin written once and closed,
|
|
5
|
+
* stdout and stderr captured separately. On the bound it signals ITS OWN child only (TERM, then KILL
|
|
6
|
+
* after a short grace); if the pipes are still held after one more grace, it closes its own read ends
|
|
7
|
+
* and settles as a timeout rather than waiting for a close that may never come. It is not a process manager: it tracks no
|
|
8
|
+
* other process, scans nothing, and never retries. Classification belongs to
|
|
9
|
+
* `classifyRemoteReceipt`; this module only reports what the child did.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import type { RemoteTransport, RemoteTransportResult } from "./remote-pair-send.ts";
|
|
14
|
+
|
|
15
|
+
const CAPTURE_CAP = 64 * 1024;
|
|
16
|
+
const KILL_GRACE_MS = 2000;
|
|
17
|
+
const FINAL_GRACE_MS = 2000;
|
|
18
|
+
|
|
19
|
+
function capped(chunks: Buffer[], size: { n: number }, chunk: Buffer): void {
|
|
20
|
+
if (size.n >= CAPTURE_CAP) return;
|
|
21
|
+
const room = CAPTURE_CAP - size.n;
|
|
22
|
+
const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
23
|
+
chunks.push(piece);
|
|
24
|
+
size.n += piece.length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const processTransport: RemoteTransport = {
|
|
28
|
+
run(argv, stdin, timeoutMs): Promise<RemoteTransportResult> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const out: Buffer[] = [];
|
|
31
|
+
const err: Buffer[] = [];
|
|
32
|
+
const outN = { n: 0 };
|
|
33
|
+
const errN = { n: 0 };
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
let spawnError: string | null = null;
|
|
36
|
+
let settled = false;
|
|
37
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
|
|
38
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
39
|
+
let finalTimer: NodeJS.Timeout | undefined;
|
|
40
|
+
const settle = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
41
|
+
if (settled) return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(bound);
|
|
44
|
+
if (killTimer) clearTimeout(killTimer);
|
|
45
|
+
if (finalTimer) clearTimeout(finalTimer);
|
|
46
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
47
|
+
resolve({
|
|
48
|
+
exitCode: spawnError ? null : code,
|
|
49
|
+
signal: spawnError ? null : signal,
|
|
50
|
+
timedOut,
|
|
51
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
52
|
+
stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const bound = setTimeout(() => {
|
|
56
|
+
timedOut = true;
|
|
57
|
+
child.kill("SIGTERM");
|
|
58
|
+
killTimer = setTimeout(() => {
|
|
59
|
+
child.kill("SIGKILL");
|
|
60
|
+
// Something else may still hold our stdout/stderr pipes (an inherited descendant,
|
|
61
|
+
// an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
|
|
62
|
+
// ends and settle as a timeout, which the receipt reads as indeterminate.
|
|
63
|
+
finalTimer = setTimeout(() => {
|
|
64
|
+
child.stdout.destroy();
|
|
65
|
+
child.stderr.destroy();
|
|
66
|
+
settle(child.exitCode, child.signalCode);
|
|
67
|
+
}, FINAL_GRACE_MS);
|
|
68
|
+
}, KILL_GRACE_MS);
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
child.stdout.on("data", (c: Buffer) => capped(out, outN, c));
|
|
71
|
+
child.stderr.on("data", (c: Buffer) => capped(err, errN, c));
|
|
72
|
+
child.on("error", (e) => {
|
|
73
|
+
spawnError = e.message;
|
|
74
|
+
});
|
|
75
|
+
// A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
|
|
76
|
+
child.stdin.on("error", () => {});
|
|
77
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
78
|
+
child.stdin.end(stdin);
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
package/run.sh
CHANGED
|
@@ -328,6 +328,9 @@ Usage:
|
|
|
328
328
|
./run.sh meta-bridge-prune # 1.0.0 meta-bridge Phase 4: LISTING-ONLY store hygiene — classify orphan/stale/ambiguous/keep, print manual rm commands, delete NOTHING ([dir] [--ttl-days N])
|
|
329
329
|
./run.sh meta-bridge-fresh-cut # the ONE generation verb (the verb every v3-only rejection names): quiesce-check live sockets/markers/native-push conversations (refusing any surface it cannot inspect), archive meta-sessions/ + meta-mailbox/ to `<dir>.archive-<ts>`, clear dead transport residue, open an empty v3 generation. No migration, no restore — the archive is forensic only. EXIT CONTRACT (#54, `--help` prints it): 0 complete / 1 NOTHING MOVED (re-run, do not setup) / 2 usage / 3 cut transition incomplete (inspect) / 4 cut complete but residue cleanup failed (`setup` may run; re-run only before new citizen birth, otherwise remove residue manually)
|
|
330
330
|
./run.sh meta-facts # #65 owner-normalized READ-ONLY store projection: deterministic JSON {schemaVersion:1, storeDir, citizens: full v3 records sorted by gardenId, defects: {filename,message}} — THE listing contract emitted by the owner so consumers stop copying the certification. No liveness/sockets/transcript contents. EXIT: 0 readable (defects in-band; missing store = empty), 2 usage, 3 unreadable ([dir])
|
|
331
|
+
./run.sh check-remote-pair-installed # #107 rail 3, CHECKOUT-ONLY: install a caller-supplied candidate tarball (--tgz <abs>) into a sandboxed consumer prefix, check its compiled remote-pair surface, run the installed `entwurf remote-pair-receive` (usage exit 2 = twin reached), run the T2 roundtrip cells against the installed bridge+entry, and audit that the subject processes resolve every module inside the consumer prefix (with a checkout control). Never repacks; no real ssh, no model
|
|
332
|
+
./run.sh check-remote-pair-installed-audit # #107 rail 3: the deterministic counterpart of check-remote-pair-installed — its checkout-invisibility acceptance primitive proven on synthetic logs (real path, root boundary). In check:vitest
|
|
333
|
+
./run.sh remote-pair-receive # #107 rail 1: the RECEIVING half of one operator-declared remote pair. Reads ONE JSON claim {from,fromAgentId,fromCwd,target,message,wantsReply} on stdin, refuses a from/target outside the declared pair before any mutation (a scope limit under the same-operator SSH account trust model, not authentication), then hands a match to the existing v2 dispatch. Ambient sender carriers are never read. Installed, run_ts runs its compiled twin (dist/scripts/remote-pair-receive.js). EXIT: 0 dispatched / 4 dispatched, the dispatch reported an error (delivery not inferred) / 3 pair refused / 2 invalid claim or declaration (JSON receipt) or wrong argv (usage on stderr, no receipt) / 1 uncaught dispatch or store throw (no receipt; the sender reads it as indeterminate) (--pair <absolute declaration file>)
|
|
331
334
|
./run.sh peer-facts # #116 M2-a owner-normalized READ-ONLY peer projection: the same listEntwurfFacts provider through the same renderer entwurf_peers uses, emitted as deterministic JSON {schemaVersion:1, storeDir, peers, diagnostics} so a consumer never re-implements the placement join (whose pi half is a measured vendor floor). NOT the same bytes as an entwurf_peers call and deliberately so: that surface is a human listing (observationLimit, text over the wire), this is a machine projection with UNBOUNDED observation, because a rationed row says `unobserved` and a machine cannot tell that from "nobody could look". `placement` stays a STRUCTURED tagged union ({kind:"herdr-pane",paneId} / unobserved / none / ambiguous) — never the human `herdr <pane>` string. Publishes NO socket coordinate (ENTWURF_DIR selects what is PROBED, #50 C4) and NO herdr agent_status / interactive_ready / screen text. One placement read per listing, no watcher. EXIT: 0 readable (diagnostics in-band; missing store = empty), 2 usage, 3 unreadable ([dir])
|
|
332
335
|
./run.sh meta-bridge-managed-keys # 0.10.0 meta-bridge: print the SSOT of settings keys entwurf OWNS (consumers read this to stay disjoint — keyset-owner invariant)
|
|
333
336
|
./run.sh check-keyset-overlap <fragment.json...> # 0.10.0 meta-bridge: PREVENTIVE keyset guard — fail if a consumer fragment collides with any pi-owned key (cross-repo; not in pnpm check)
|
|
@@ -3387,6 +3390,17 @@ check_acp_sdk_surface() {
|
|
|
3387
3390
|
run_vitest test/acp-sdk-surface.contract.test.ts
|
|
3388
3391
|
}
|
|
3389
3392
|
|
|
3393
|
+
check_remote_pair_installed_audit() {
|
|
3394
|
+
# #107 rail 3. The deterministic counterpart of `check-remote-pair-installed`: that gate needs a
|
|
3395
|
+
# caller-supplied tarball, so it cannot run in the floor or carry mutants, and its
|
|
3396
|
+
# checkout-invisibility verdict rests on one acceptance primitive
|
|
3397
|
+
# (scripts/lib/remote-pair-installed-audit.ts). This proves that primitive on tiny synthetic
|
|
3398
|
+
# logs and files. `test/**` is not discovered by check-tests-beside-behavior, so it needs this
|
|
3399
|
+
# public coordinate. Zero network, zero child process.
|
|
3400
|
+
section "remote-pair installed-closure audit (synthetic logs)"
|
|
3401
|
+
run_vitest test/remote-pair-installed-audit.test.ts
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3390
3404
|
check_pi_queue_oracle() {
|
|
3391
3405
|
# #120 P2. The PUBLIC execution coordinate for `test/pi-queue.oracle.test.ts`, and the reason it
|
|
3392
3406
|
# needs one: the discovery door below selects `pi-extensions/**/*.test.ts` and
|
|
@@ -3829,6 +3843,12 @@ check_pack() {
|
|
|
3829
3843
|
# out: the placement join it emits is the copy a herdr-side consumer would
|
|
3830
3844
|
# otherwise fork, and such a consumer runs against an installed host.
|
|
3831
3845
|
"mcp/entwurf-bridge/dist/scripts/peer-facts.js"
|
|
3846
|
+
# #107 — the receiving host's entry the sender's ssh runs (run_ts twin), plus the three
|
|
3847
|
+
# remote-pair libs the bridge's entwurf_v2 handler and that entry import.
|
|
3848
|
+
"mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js"
|
|
3849
|
+
"mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js"
|
|
3850
|
+
"mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js"
|
|
3851
|
+
"mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js"
|
|
3832
3852
|
# #87 Bundle B — omp-receive-doctor drives this operator projection through
|
|
3833
3853
|
# run.sh, so installed node_modules must have the compiled twin too.
|
|
3834
3854
|
"mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js"
|
|
@@ -4570,6 +4590,12 @@ _check_pack_install_impl() {
|
|
|
4570
4590
|
"mcp/entwurf-bridge/dist/scripts/meta-facts.js"
|
|
4571
4591
|
# #116 M2-a — the owner-normalized peer projection (see check-pack).
|
|
4572
4592
|
"mcp/entwurf-bridge/dist/scripts/peer-facts.js"
|
|
4593
|
+
# #107 — the receiving host's entry the sender's ssh runs (run_ts twin), plus the three
|
|
4594
|
+
# remote-pair libs the bridge's entwurf_v2 handler and that entry import.
|
|
4595
|
+
"mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js"
|
|
4596
|
+
"mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js"
|
|
4597
|
+
"mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js"
|
|
4598
|
+
"mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js"
|
|
4573
4599
|
# #87 Bundle B — omp-receive-doctor reaches this through the installed dispatcher.
|
|
4574
4600
|
"mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js"
|
|
4575
4601
|
# 0.12.5 — node_modules-safe plugin hook + lib (see check-pack). The installed
|
|
@@ -8412,6 +8438,21 @@ case "$cmd" in
|
|
|
8412
8438
|
shift || true
|
|
8413
8439
|
run_ts scripts/meta-facts.ts "$@"
|
|
8414
8440
|
;;
|
|
8441
|
+
check-remote-pair-installed)
|
|
8442
|
+
# #107 rail 3, CHECKOUT-ONLY install gate: installs a caller-supplied candidate tarball into a
|
|
8443
|
+
# sandboxed consumer prefix and drives that install's bridge and entry through the T2 scene,
|
|
8444
|
+
# auditing that the subject processes resolve nothing outside the prefix. Never repacks.
|
|
8445
|
+
shift || true
|
|
8446
|
+
run_ts scripts/check-remote-pair-installed.ts "$@"
|
|
8447
|
+
;;
|
|
8448
|
+
remote-pair-receive)
|
|
8449
|
+
# #107 rail 1: the receiving host's entry for one declared remote pair. The pair
|
|
8450
|
+
# check runs before the v2 dispatch, so a claim outside the pair mutates nothing;
|
|
8451
|
+
# a match reaches the same record/owner/deliverability checks every local send does.
|
|
8452
|
+
# Installed, run_ts runs its compiled twin; the sending host's ssh reaches it through the bin.
|
|
8453
|
+
shift || true
|
|
8454
|
+
run_ts scripts/remote-pair-receive.ts "$@"
|
|
8455
|
+
;;
|
|
8415
8456
|
peer-facts)
|
|
8416
8457
|
# #116 M2-a READ-ONLY projection of the OBSERVED peer listing: one provider
|
|
8417
8458
|
# (`listEntwurfFacts`) through one renderer (`renderEntwurfPeers().payload`), so
|
|
@@ -8481,6 +8522,9 @@ case "$cmd" in
|
|
|
8481
8522
|
check-pi-queue-oracle)
|
|
8482
8523
|
check_pi_queue_oracle
|
|
8483
8524
|
;;
|
|
8525
|
+
check-remote-pair-installed-audit)
|
|
8526
|
+
check_remote_pair_installed_audit
|
|
8527
|
+
;;
|
|
8484
8528
|
check-demo-reproducibility)
|
|
8485
8529
|
check_demo_reproducibility
|
|
8486
8530
|
;;
|