@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
|
@@ -0,0 +1,111 @@
|
|
|
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 type { RetireResult } from "./control-retire.js";
|
|
8
|
+
import { controlSocketPathIn, defaultControlSocketDir } from "./control-socket-path.js";
|
|
9
|
+
import { type RpcRetireCommand, sendRpcCommand } from "./entwurf-control-rpc.ts";
|
|
10
|
+
import type { MetaIdentity } from "./meta-session.js";
|
|
11
|
+
import { SESSION_ID_RE } from "./session-id.js";
|
|
12
|
+
|
|
13
|
+
type ReceiverRefusal = Extract<RetireResult, { boundary: "refused" }>["reason"];
|
|
14
|
+
const RECEIVER_REFUSALS: readonly ReceiverRefusal[] = [
|
|
15
|
+
"target-closing",
|
|
16
|
+
"target-stale",
|
|
17
|
+
"target-context-unavailable",
|
|
18
|
+
"target-non-tui",
|
|
19
|
+
"target-busy",
|
|
20
|
+
"target-pending",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export type PiRetireRequestResult =
|
|
24
|
+
| { boundary: "shutdown-requested" }
|
|
25
|
+
| { boundary: "refused"; phase: "preflight"; reason: "target-backend-unsupported" | "target-identity-mismatch" }
|
|
26
|
+
| { boundary: "refused"; phase: "receiver"; reason: ReceiverRefusal }
|
|
27
|
+
| { boundary: "unknown"; reason: "malformed-response" | "transport-error" | "shutdown-call-failed" };
|
|
28
|
+
|
|
29
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
34
|
+
const actual = Object.keys(value);
|
|
35
|
+
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Closed wire combinations only. A malformed answer AFTER sending cannot prove no action.
|
|
39
|
+
* SDK request acceptance is not exit, persistence/flush, or no-further-model-activity evidence.
|
|
40
|
+
*/
|
|
41
|
+
export function parsePiRetireResponse(response: unknown): PiRetireRequestResult {
|
|
42
|
+
const malformed: PiRetireRequestResult = { boundary: "unknown", reason: "malformed-response" };
|
|
43
|
+
if (!object(response) || response.type !== "response" || response.command !== "retire") return malformed;
|
|
44
|
+
if (Object.keys(response).some((key) => !["type", "command", "success", "data", "error", "id"].includes(key))) {
|
|
45
|
+
return malformed;
|
|
46
|
+
}
|
|
47
|
+
if (Object.hasOwn(response, "id") && typeof response.id !== "string") return malformed;
|
|
48
|
+
const data = response.data;
|
|
49
|
+
if (!object(data)) return malformed;
|
|
50
|
+
if (response.success === true && data.boundary === "shutdown-requested") {
|
|
51
|
+
if (!exactKeys(data, ["boundary"]) || Object.hasOwn(response, "error")) return malformed;
|
|
52
|
+
return { boundary: "shutdown-requested" };
|
|
53
|
+
}
|
|
54
|
+
if (response.success !== false || !exactKeys(data, ["boundary", "reason"]) || response.error !== data.reason) {
|
|
55
|
+
return malformed;
|
|
56
|
+
}
|
|
57
|
+
if (data.boundary === "refused" && RECEIVER_REFUSALS.includes(data.reason as ReceiverRefusal)) {
|
|
58
|
+
return { boundary: "refused", phase: "receiver", reason: data.reason as ReceiverRefusal };
|
|
59
|
+
}
|
|
60
|
+
if (data.boundary === "unknown" && data.reason === "shutdown-call-failed") {
|
|
61
|
+
return { boundary: "unknown", reason: "shutdown-call-failed" };
|
|
62
|
+
}
|
|
63
|
+
return malformed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PiRetireRequestOptions {
|
|
67
|
+
/** Internal composition root's existing socket directory; never a target-supplied path. */
|
|
68
|
+
controlSocketDir?: string;
|
|
69
|
+
/** Bounds this ONE transport request; a timeout never starts a retry/fallback. */
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
/** Existing RPC seam, injectable for deterministic wire-result tests. */
|
|
72
|
+
sendRpc?: (
|
|
73
|
+
socketPath: string,
|
|
74
|
+
command: RpcRetireCommand,
|
|
75
|
+
options: { timeout: number },
|
|
76
|
+
) => Promise<{ response: unknown }>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function requestPiRetire(
|
|
80
|
+
target: MetaIdentity,
|
|
81
|
+
expected: { gardenId: string; nativeSessionId: string },
|
|
82
|
+
options: PiRetireRequestOptions = {},
|
|
83
|
+
): Promise<PiRetireRequestResult> {
|
|
84
|
+
if (target.backend !== "pi") {
|
|
85
|
+
return { boundary: "refused", phase: "preflight", reason: "target-backend-unsupported" };
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
target.gardenId !== expected.gardenId ||
|
|
89
|
+
target.nativeSessionId !== expected.nativeSessionId ||
|
|
90
|
+
!SESSION_ID_RE.test(target.gardenId) ||
|
|
91
|
+
target.nativeSessionId.length === 0
|
|
92
|
+
) {
|
|
93
|
+
return { boundary: "refused", phase: "preflight", reason: "target-identity-mismatch" };
|
|
94
|
+
}
|
|
95
|
+
const socketPath = controlSocketPathIn(
|
|
96
|
+
options.controlSocketDir ?? defaultControlSocketDir(os.homedir()),
|
|
97
|
+
target.gardenId,
|
|
98
|
+
);
|
|
99
|
+
try {
|
|
100
|
+
const { response } = await (options.sendRpc ?? sendRpcCommand)(
|
|
101
|
+
socketPath,
|
|
102
|
+
{ type: "retire", gardenId: target.gardenId, nativeSessionId: target.nativeSessionId },
|
|
103
|
+
{ timeout: options.timeoutMs ?? 5000 },
|
|
104
|
+
);
|
|
105
|
+
return parsePiRetireResponse(response);
|
|
106
|
+
} catch {
|
|
107
|
+
// The RPC API does not expose whether bytes were written. Even connection errors
|
|
108
|
+
// are conservatively unknown here; do not infer a safe pre-send refusal or retry.
|
|
109
|
+
return { boundary: "unknown", reason: "transport-error" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|