@sagentlab/navarch-runtime 0.1.112 → 0.1.117
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/README.md +56 -18
- package/bin/muse-mcp-bridge.cjs +3 -0
- package/dist/adapters/index.cjs +6 -1
- package/dist/adapters/muse.cjs +433 -0
- package/dist/agent-auth.cjs +2 -1
- package/dist/cli.cjs +4 -1
- package/dist/config.cjs +9 -1
- package/dist/conversation/capability.cjs +1 -1
- package/dist/conversation/loop.cjs +16 -0
- package/dist/conversation/mirror.cjs +225 -0
- package/dist/conversation/workspace.cjs +11 -4
- package/dist/exit-conditions.cjs +1 -1
- package/dist/muse-mcp-bridge.cjs +167 -0
- package/dist/session.cjs +30 -8
- package/dist/worktree-guard.cjs +137 -3
- package/node_modules/@muse-code/sdk/LICENSE +21 -0
- package/node_modules/@muse-code/sdk/README.md +88 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
- package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
- package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
- package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
- package/node_modules/@muse-code/sdk/package.json +47 -0
- package/package.json +8 -4
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Node stdio binding and type-state handshake for the MSP connection. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from "node:child_process";
|
|
4
|
+
import type { InitializeParams, InitializeResult } from "../msp.js";
|
|
5
|
+
import { Connection } from "./connection.js";
|
|
6
|
+
import type { ConnectionOptions, DuplexTransport, NotificationHandler, ProtocolErrorHandler, ServerRequestHandler } from "./connection.js";
|
|
7
|
+
import type { FingerprintWarning } from "../fingerprint.js";
|
|
8
|
+
/**
|
|
9
|
+
* What `spawnMspConnection` needs to launch a host: the command to run, plus
|
|
10
|
+
* optional arguments, working directory, environment, connection tuning, a
|
|
11
|
+
* stderr tap, and the shutdown drain budget.
|
|
12
|
+
*/
|
|
13
|
+
export interface SpawnMspConnectionOptions {
|
|
14
|
+
readonly command: string;
|
|
15
|
+
readonly args?: readonly string[];
|
|
16
|
+
readonly cwd?: string;
|
|
17
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
18
|
+
readonly connection?: ConnectionOptions;
|
|
19
|
+
/** Raw stderr chunks, drained from process birth and never parsed. */
|
|
20
|
+
readonly onStderr?: (chunk: string) => void;
|
|
21
|
+
/** See {@link MuseServeChildOptions.shutdownTimeoutMs}. */
|
|
22
|
+
readonly shutdownTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* How the host process ended: its exit code, or the signal that ended it.
|
|
26
|
+
* Exactly one of the two is non-null.
|
|
27
|
+
*/
|
|
28
|
+
export interface ProcessExit {
|
|
29
|
+
readonly code: number | null;
|
|
30
|
+
readonly signal: NodeJS.Signals | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The SDK's reading of a host exit. Branch on `kind`: a clean shutdown, a
|
|
34
|
+
* crash, an unhandled error, or one of the documented error exits. Every arm
|
|
35
|
+
* but `cleanShutdown` carries its exit evidence (code or signal plus a
|
|
36
|
+
* bounded stderr tail), and, where the meaning is known, whether retrying
|
|
37
|
+
* can help.
|
|
38
|
+
*/
|
|
39
|
+
export type ExitClassification = {
|
|
40
|
+
readonly kind: "cleanShutdown";
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: "unhandledError";
|
|
43
|
+
readonly exitCode: number;
|
|
44
|
+
readonly stderrTail: readonly string[];
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: "usageError" | "configError" | "leaseUnavailable" | "sdkSurfaceUnavailable";
|
|
47
|
+
readonly exitCode: number;
|
|
48
|
+
readonly stderrTail: readonly string[];
|
|
49
|
+
readonly retry: "never" | "fix-config" | "after-lease-release";
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "crash";
|
|
52
|
+
readonly exitCode: number | null;
|
|
53
|
+
readonly exitSignal: string | null;
|
|
54
|
+
readonly stderrTail: readonly string[];
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Options for launching an SDK-owned host process: the binary to run, plus
|
|
58
|
+
* optional arguments, working directory, environment, a stderr tap, and the
|
|
59
|
+
* shutdown drain budget.
|
|
60
|
+
*/
|
|
61
|
+
export interface MuseServeChildOptions {
|
|
62
|
+
readonly museBin: string;
|
|
63
|
+
readonly args?: readonly string[];
|
|
64
|
+
readonly cwd?: string;
|
|
65
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
66
|
+
/** Raw evidence callback for consumers that also keep a full diagnostic log. */
|
|
67
|
+
readonly onStderr?: (chunk: string) => void;
|
|
68
|
+
/**
|
|
69
|
+
* How long `close()` lets the host drain after stdin EOF before the SDK
|
|
70
|
+
* ends the process it owns. Defaults to SS2.1.2's 30 s host drain timeout;
|
|
71
|
+
* set it to match a host configured with a different one. `0` skips the
|
|
72
|
+
* drain window (SIGTERM at once) — it is NOT an instant kill: the fixed
|
|
73
|
+
* SIGTERM→SIGKILL grace still runs. Must be an integer in
|
|
74
|
+
* `0..2147483647`; anything else throws `RangeError` at spawn.
|
|
75
|
+
*/
|
|
76
|
+
readonly shutdownTimeoutMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Deterministic-test seam; production always uses `child_process.spawn`.
|
|
79
|
+
* A stub here observes the exact options the call site passes, so a
|
|
80
|
+
* `windowsHide` override at the call site fails the pin (FR-45141-1).
|
|
81
|
+
*/
|
|
82
|
+
readonly spawnFn?: typeof spawn;
|
|
83
|
+
}
|
|
84
|
+
declare const ownedTransport: unique symbol;
|
|
85
|
+
declare const adoptFlushSource: unique symbol;
|
|
86
|
+
/**
|
|
87
|
+
* Module friend seam: did `initialize` get a usable answer before the
|
|
88
|
+
* handshake failed? Exported from this MODULE only (never the package
|
|
89
|
+
* barrel) so the facade can word a host lost AFTER answering truthfully
|
|
90
|
+
* (#49912) without a new public member on `MspHandshake`.
|
|
91
|
+
*/
|
|
92
|
+
export declare const initializeAnswered: unique symbol;
|
|
93
|
+
/**
|
|
94
|
+
* Shutdown deadline contract: `expired` resolves and never rejects; after
|
|
95
|
+
* `clear()` it may remain pending forever.
|
|
96
|
+
*/
|
|
97
|
+
interface ShutdownDeadline {
|
|
98
|
+
readonly expired: Promise<void>;
|
|
99
|
+
clear(): void;
|
|
100
|
+
}
|
|
101
|
+
type ShutdownDeadlineFactory = (budgetMs: number) => ShutdownDeadline;
|
|
102
|
+
/**
|
|
103
|
+
* Is this `stdin.end()` error the benign close race — the child exited
|
|
104
|
+
* between the liveness check and the end() call, so its stdin is already
|
|
105
|
+
* gone? That IS the closed end-state close() wants (SS2.11: the caller
|
|
106
|
+
* still awaits `exited` and gates on the exit code); anything else is a
|
|
107
|
+
* real failure. `ECANCELED` is the same owned-teardown race one stage later:
|
|
108
|
+
* the SIGTERM->SIGKILL backstop destroys stdin while a wedged write is still
|
|
109
|
+
* queued, and libuv cancels that write instead of erroring it. Exported only
|
|
110
|
+
* for its deterministic unit test.
|
|
111
|
+
*/
|
|
112
|
+
export declare function isBenignCloseRace(error: unknown): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Internal transport over an already-spawned child's stdio. Exported from
|
|
115
|
+
* this MODULE only (never the package barrel) so the deterministic
|
|
116
|
+
* close()-race tests can construct it around a stub child — the live arm
|
|
117
|
+
* awaits `exited` first and cannot reach the race branch (PR #15641 review).
|
|
118
|
+
*/
|
|
119
|
+
export declare class ChildStdioTransport implements DuplexTransport {
|
|
120
|
+
#private;
|
|
121
|
+
readonly child: ChildProcessWithoutNullStreams;
|
|
122
|
+
readonly incoming: AsyncIterable<string>;
|
|
123
|
+
readonly exited: Promise<ProcessExit>;
|
|
124
|
+
constructor(child: ChildProcessWithoutNullStreams, onStderr?: (chunk: string) => void, shutdownTimeoutMs?: number, ownsProcessGroup?: boolean,
|
|
125
|
+
/** Deterministic-test seam; production always uses `makeShutdownDeadline`. */
|
|
126
|
+
deadlineFactory?: ShutdownDeadlineFactory);
|
|
127
|
+
write(chunk: string): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* The single owner of shutdown (#15943). EOF first, then bounded: SS2.1.2
|
|
130
|
+
* gives the host a drain window and says the remainder past it has crash
|
|
131
|
+
* semantics, so past the window the SDK ends the process it owns rather
|
|
132
|
+
* than waiting on an EOF that may never come. Memoized, so concurrent and
|
|
133
|
+
* repeated calls share one attempt and one set of timers.
|
|
134
|
+
*/
|
|
135
|
+
/** Module-private friend seam used by the handshake that owns both sides. */
|
|
136
|
+
[adoptFlushSource](source: () => Promise<void>): void;
|
|
137
|
+
close(flushed?: Promise<void>): Promise<void>;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The exact options `MuseServeChild.spawn` hands to `child_process.spawn`,
|
|
141
|
+
* factored out so the spawn-options pin can assert on them without spawning.
|
|
142
|
+
* Exported from this MODULE only (never the package barrel) — the same
|
|
143
|
+
* module-only-test-seam precedent as `isBenignCloseRace`.
|
|
144
|
+
*/
|
|
145
|
+
export declare function buildSpawnOptions(options: {
|
|
146
|
+
readonly cwd?: string;
|
|
147
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
148
|
+
readonly detached: boolean;
|
|
149
|
+
}): SpawnOptionsWithoutStdio;
|
|
150
|
+
/** One owned MSP host process with SS2.11 diagnostics and total exit mapping. */
|
|
151
|
+
export declare class MuseServeChild {
|
|
152
|
+
#private;
|
|
153
|
+
readonly exit: Promise<ExitClassification>;
|
|
154
|
+
private constructor();
|
|
155
|
+
static spawn(options: MuseServeChildOptions): MuseServeChild;
|
|
156
|
+
get stderrTail(): readonly string[];
|
|
157
|
+
/** Module-private friend seam used by the handshake machine for this child. */
|
|
158
|
+
[ownedTransport](): ChildStdioTransport;
|
|
159
|
+
close(): Promise<ExitClassification>;
|
|
160
|
+
}
|
|
161
|
+
/** The only public state before `initialized`: callers cannot send traffic. */
|
|
162
|
+
export declare class MspHandshake {
|
|
163
|
+
#private;
|
|
164
|
+
readonly child: MuseServeChild;
|
|
165
|
+
constructor(options: SpawnMspConnectionOptions);
|
|
166
|
+
get exited(): Promise<ProcessExit>;
|
|
167
|
+
onServerRequest(handler: ServerRequestHandler): void;
|
|
168
|
+
onNotification(handler: NotificationHandler): void;
|
|
169
|
+
onProtocolError(handler: ProtocolErrorHandler): void;
|
|
170
|
+
close(): Promise<ProcessExit>;
|
|
171
|
+
/** See {@link initializeAnswered}. */
|
|
172
|
+
[initializeAnswered](): boolean;
|
|
173
|
+
initialize(params: InitializeParams): Promise<SpawnedMspConnection>;
|
|
174
|
+
}
|
|
175
|
+
/** A fully initialized connection plus its owned process boundary. */
|
|
176
|
+
export declare class SpawnedMspConnection {
|
|
177
|
+
#private;
|
|
178
|
+
readonly connection: Connection;
|
|
179
|
+
readonly child: MuseServeChild;
|
|
180
|
+
readonly initializeResult: InitializeResult;
|
|
181
|
+
readonly fingerprintWarning: FingerprintWarning | undefined;
|
|
182
|
+
readonly exited: Promise<ProcessExit>;
|
|
183
|
+
constructor(connection: Connection, transport: ChildStdioTransport, child: MuseServeChild, initializeResult: InitializeResult, fingerprintWarning: FingerprintWarning | undefined);
|
|
184
|
+
close(): Promise<ProcessExit>;
|
|
185
|
+
}
|
|
186
|
+
/** Spawn one owned host and begin its SS1.4 handshake state machine. */
|
|
187
|
+
export declare function spawnMspConnection(options: SpawnMspConnectionOptions): MspHandshake;
|
|
188
|
+
export {};
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/** Node stdio binding and type-state handshake for the MSP connection. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { Connection, ProtocolError, requestWithFrame, submissionTail, } from "./connection.js";
|
|
4
|
+
import { checkServedFingerprint } from "../fingerprint.js";
|
|
5
|
+
const DEFAULT_STDERR_MAX_BYTES = 8 * 1024;
|
|
6
|
+
const DEFAULT_STDERR_MAX_LINES = 100;
|
|
7
|
+
/** SS2.1.2's default host drain timeout: the whole EOF sequence's budget. */
|
|
8
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* SIGTERM → SIGKILL grace. Not a knob: by the time it runs the host has
|
|
11
|
+
* already had its whole drain window, so this covers only a signal handler's
|
|
12
|
+
* final flush. Codex's stdio launcher uses the same two seconds.
|
|
13
|
+
*/
|
|
14
|
+
const SIGTERM_GRACE_MS = 2_000;
|
|
15
|
+
/** Node's own `setTimeout` ceiling; past it a delay silently clamps to 1 ms. */
|
|
16
|
+
const MAX_SHUTDOWN_TIMEOUT_MS = 2_147_483_647;
|
|
17
|
+
const ownedTransport = Symbol("MuseServeChild.transport");
|
|
18
|
+
const adoptFlushSource = Symbol("ChildStdioTransport.adoptFlushSource");
|
|
19
|
+
/**
|
|
20
|
+
* Module friend seam: did `initialize` get a usable answer before the
|
|
21
|
+
* handshake failed? Exported from this MODULE only (never the package
|
|
22
|
+
* barrel) so the facade can word a host lost AFTER answering truthfully
|
|
23
|
+
* (#49912) without a new public member on `MspHandshake`.
|
|
24
|
+
*/
|
|
25
|
+
export const initializeAnswered = Symbol("MspHandshake.initializeAnswered");
|
|
26
|
+
function makeShutdownDeadline(budgetMs) {
|
|
27
|
+
let timer;
|
|
28
|
+
const expired = new Promise((resolve) => {
|
|
29
|
+
timer = setTimeout(resolve, budgetMs);
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
expired,
|
|
33
|
+
clear: () => {
|
|
34
|
+
if (timer !== undefined)
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
timer = undefined;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** A client-local evidence budget. Its contents never influence behavior. */
|
|
41
|
+
class StderrTail {
|
|
42
|
+
#text = "";
|
|
43
|
+
push(chunk) {
|
|
44
|
+
this.#text += chunk;
|
|
45
|
+
this.#trimLines();
|
|
46
|
+
this.#trimBytes();
|
|
47
|
+
}
|
|
48
|
+
lines() {
|
|
49
|
+
if (this.#text.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const lines = this.#text.split("\n");
|
|
52
|
+
if (lines.at(-1) === "")
|
|
53
|
+
lines.pop();
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
#trimLines() {
|
|
57
|
+
let lineCount = this.#text.split("\n").length;
|
|
58
|
+
if (this.#text.endsWith("\n"))
|
|
59
|
+
lineCount -= 1;
|
|
60
|
+
while (lineCount > DEFAULT_STDERR_MAX_LINES) {
|
|
61
|
+
const firstBreak = this.#text.indexOf("\n");
|
|
62
|
+
if (firstBreak < 0)
|
|
63
|
+
break;
|
|
64
|
+
this.#text = this.#text.slice(firstBreak + 1);
|
|
65
|
+
lineCount -= 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
#trimBytes() {
|
|
69
|
+
const bytes = Buffer.from(this.#text, "utf8");
|
|
70
|
+
if (bytes.byteLength <= DEFAULT_STDERR_MAX_BYTES)
|
|
71
|
+
return;
|
|
72
|
+
let start = bytes.byteLength - DEFAULT_STDERR_MAX_BYTES;
|
|
73
|
+
// If the byte budget cuts through a UTF-8 scalar, advance to its first
|
|
74
|
+
// complete successor rather than manufacturing replacement text.
|
|
75
|
+
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80)
|
|
76
|
+
start += 1;
|
|
77
|
+
this.#text = bytes.subarray(start).toString("utf8");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function classifyExit(processExit, stderrTail) {
|
|
81
|
+
const evidence = [...stderrTail];
|
|
82
|
+
switch (processExit.code) {
|
|
83
|
+
case 0:
|
|
84
|
+
return { kind: "cleanShutdown" };
|
|
85
|
+
case 1:
|
|
86
|
+
return { kind: "unhandledError", exitCode: 1, stderrTail: evidence };
|
|
87
|
+
case 2:
|
|
88
|
+
return { kind: "usageError", exitCode: 2, stderrTail: evidence, retry: "never" };
|
|
89
|
+
case 3:
|
|
90
|
+
return { kind: "configError", exitCode: 3, stderrTail: evidence, retry: "fix-config" };
|
|
91
|
+
case 4:
|
|
92
|
+
// Exit 4 is `sessionInUse` arriving early: another live client holds the
|
|
93
|
+
// lease, and the lease frees itself once that client exits (SS2.11).
|
|
94
|
+
return {
|
|
95
|
+
kind: "leaseUnavailable",
|
|
96
|
+
exitCode: 4,
|
|
97
|
+
stderrTail: evidence,
|
|
98
|
+
retry: "after-lease-release",
|
|
99
|
+
};
|
|
100
|
+
case 5:
|
|
101
|
+
return {
|
|
102
|
+
kind: "sdkSurfaceUnavailable",
|
|
103
|
+
exitCode: 5,
|
|
104
|
+
stderrTail: evidence,
|
|
105
|
+
retry: "never",
|
|
106
|
+
};
|
|
107
|
+
default:
|
|
108
|
+
return {
|
|
109
|
+
kind: "crash",
|
|
110
|
+
exitCode: processExit.code,
|
|
111
|
+
exitSignal: processExit.signal,
|
|
112
|
+
stderrTail: evidence,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Is this `stdin.end()` error the benign close race — the child exited
|
|
118
|
+
* between the liveness check and the end() call, so its stdin is already
|
|
119
|
+
* gone? That IS the closed end-state close() wants (SS2.11: the caller
|
|
120
|
+
* still awaits `exited` and gates on the exit code); anything else is a
|
|
121
|
+
* real failure. `ECANCELED` is the same owned-teardown race one stage later:
|
|
122
|
+
* the SIGTERM->SIGKILL backstop destroys stdin while a wedged write is still
|
|
123
|
+
* queued, and libuv cancels that write instead of erroring it. Exported only
|
|
124
|
+
* for its deterministic unit test.
|
|
125
|
+
*/
|
|
126
|
+
export function isBenignCloseRace(error) {
|
|
127
|
+
const code = error?.code;
|
|
128
|
+
return code === "ERR_STREAM_DESTROYED" || code === "EPIPE" || code === "ECANCELED";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Node clamps a `setTimeout` delay that is non-finite or > 2^31-1 down to
|
|
132
|
+
* 1 ms, so an out-of-range budget would silently become "kill almost
|
|
133
|
+
* immediately" and fabricate a crash row for a host that was draining
|
|
134
|
+
* normally — a row FM-002's ephemeral discard then acts on (PR #22819
|
|
135
|
+
* review). Refuse it at the owning boundary instead of clamping.
|
|
136
|
+
*/
|
|
137
|
+
function assertShutdownTimeout(shutdownTimeoutMs) {
|
|
138
|
+
if (!Number.isInteger(shutdownTimeoutMs) ||
|
|
139
|
+
shutdownTimeoutMs < 0 ||
|
|
140
|
+
shutdownTimeoutMs > MAX_SHUTDOWN_TIMEOUT_MS) {
|
|
141
|
+
throw new RangeError(`shutdownTimeoutMs must be an integer in 0..${MAX_SHUTDOWN_TIMEOUT_MS}, got ${String(shutdownTimeoutMs)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Internal transport over an already-spawned child's stdio. Exported from
|
|
146
|
+
* this MODULE only (never the package barrel) so the deterministic
|
|
147
|
+
* close()-race tests can construct it around a stub child — the live arm
|
|
148
|
+
* awaits `exited` first and cannot reach the race branch (PR #15641 review).
|
|
149
|
+
*/
|
|
150
|
+
export class ChildStdioTransport {
|
|
151
|
+
child;
|
|
152
|
+
incoming;
|
|
153
|
+
exited;
|
|
154
|
+
#shutdownTimeoutMs;
|
|
155
|
+
#makeShutdownDeadline;
|
|
156
|
+
// True only when the boundary that SPAWNED this child made it a process-
|
|
157
|
+
// group leader (FR-017b). Never inferred from the child itself: a test
|
|
158
|
+
// handing the transport an ordinary child keeps the direct-kill path.
|
|
159
|
+
#ownsProcessGroup;
|
|
160
|
+
#closing;
|
|
161
|
+
// The default `flushed` for closes routed AROUND the connection — notably
|
|
162
|
+
// the public `child.close()`. Without it, step 0 existed only for
|
|
163
|
+
// Connection-routed closes while the contract claimed all three surfaces
|
|
164
|
+
// behaved identically, and `#closing ??=` let whichever call won the race
|
|
165
|
+
// discard the other's submission promise (PR #22819 round 4).
|
|
166
|
+
#defaultFlushed;
|
|
167
|
+
constructor(child, onStderr, shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS, ownsProcessGroup = false,
|
|
168
|
+
/** Deterministic-test seam; production always uses `makeShutdownDeadline`. */
|
|
169
|
+
deadlineFactory = makeShutdownDeadline) {
|
|
170
|
+
this.child = child;
|
|
171
|
+
assertShutdownTimeout(shutdownTimeoutMs);
|
|
172
|
+
this.#shutdownTimeoutMs = shutdownTimeoutMs;
|
|
173
|
+
this.#ownsProcessGroup = ownsProcessGroup;
|
|
174
|
+
this.#makeShutdownDeadline = deadlineFactory;
|
|
175
|
+
this.child.stdout.setEncoding("utf8");
|
|
176
|
+
this.child.stderr.setEncoding("utf8");
|
|
177
|
+
this.child.stderr.on("data", (chunk) => onStderr?.(chunk));
|
|
178
|
+
// Node delivers a failed stdin write to BOTH the write callback and a
|
|
179
|
+
// later 'error' event; with no listener that event kills the embedding
|
|
180
|
+
// process (FM-001). The permanent no-op absorbs it — the write/end
|
|
181
|
+
// callbacks below carry the same error to the real rejection path.
|
|
182
|
+
this.child.stdin.on("error", () => { });
|
|
183
|
+
this.incoming = this.#stdoutChunks();
|
|
184
|
+
this.exited = new Promise((resolve, reject) => {
|
|
185
|
+
this.child.once("error", reject);
|
|
186
|
+
this.child.once("close", (code, signal) => resolve({ code, signal }));
|
|
187
|
+
});
|
|
188
|
+
// Mark handled at birth: a spawn failure (e.g. ENOENT) rejects `exited`
|
|
189
|
+
// before anyone can await it, and the default unhandled-rejection policy
|
|
190
|
+
// would kill the consumer. Later awaiters still receive the rejection.
|
|
191
|
+
this.exited.catch(() => { });
|
|
192
|
+
}
|
|
193
|
+
async write(chunk) {
|
|
194
|
+
if (this.child.stdin.destroyed)
|
|
195
|
+
throw new ProtocolError("spawned MSP host stdin is closed");
|
|
196
|
+
await new Promise((resolve, reject) => {
|
|
197
|
+
this.child.stdin.write(chunk, "utf8", (error) => (error ? reject(error) : resolve()));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The single owner of shutdown (#15943). EOF first, then bounded: SS2.1.2
|
|
202
|
+
* gives the host a drain window and says the remainder past it has crash
|
|
203
|
+
* semantics, so past the window the SDK ends the process it owns rather
|
|
204
|
+
* than waiting on an EOF that may never come. Memoized, so concurrent and
|
|
205
|
+
* repeated calls share one attempt and one set of timers.
|
|
206
|
+
*/
|
|
207
|
+
/** Module-private friend seam used by the handshake that owns both sides. */
|
|
208
|
+
[adoptFlushSource](source) {
|
|
209
|
+
this.#defaultFlushed = source;
|
|
210
|
+
}
|
|
211
|
+
async close(flushed) {
|
|
212
|
+
this.#closing ??= this.#shutdown(flushed ?? this.#defaultFlushed?.());
|
|
213
|
+
await this.#closing;
|
|
214
|
+
}
|
|
215
|
+
async #shutdown(flushed) {
|
|
216
|
+
// ONE budget covers the whole shutdown, flush wait included, so handing
|
|
217
|
+
// the connection's submission promise in here cannot double the bound.
|
|
218
|
+
const deadline = this.#makeShutdownDeadline(this.#shutdownTimeoutMs);
|
|
219
|
+
try {
|
|
220
|
+
// Frames the connection accepted before close() must reach the stream
|
|
221
|
+
// ahead of EOF (PR #22819 round 3); a peer that never drains simply
|
|
222
|
+
// spends the drain window here instead of after EOF.
|
|
223
|
+
if (flushed !== undefined)
|
|
224
|
+
await this.#beforeDeadline(flushed, deadline.expired);
|
|
225
|
+
return await this.#shutdownAfterFlush(deadline.expired);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
deadline.clear();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async #shutdownAfterFlush(deadline) {
|
|
232
|
+
// The EOF attempt must NEVER gate the ladder (PR #22819 review, P0). Two
|
|
233
|
+
// reachable paths leave it unsettled forever, and both are the #15943
|
|
234
|
+
// wedge itself: a host that stopped READING stdin never flushes a filled
|
|
235
|
+
// pipe, so `end()`'s callback never fires; and a non-benign end() error
|
|
236
|
+
// would reject here, before a single signal is sent — memoized, so every
|
|
237
|
+
// later close() replays that rejection with the child still alive. Start
|
|
238
|
+
// the clock regardless and await the EOF outcome AFTER the child is gone,
|
|
239
|
+
// where a pending write settles as the benign EPIPE race and a real error
|
|
240
|
+
// still surfaces to the caller.
|
|
241
|
+
const eof = this.#endStdin();
|
|
242
|
+
eof.catch(() => { });
|
|
243
|
+
await this.#awaitExitOrTerminate(deadline);
|
|
244
|
+
await eof;
|
|
245
|
+
}
|
|
246
|
+
async #endStdin() {
|
|
247
|
+
if (this.child.stdin.destroyed || this.child.stdin.writableEnded)
|
|
248
|
+
return;
|
|
249
|
+
await new Promise((resolve, reject) => {
|
|
250
|
+
// The destroyed/writableEnded guard above is racy: the child can exit
|
|
251
|
+
// between it and end(), and Node then reports ERR_STREAM_DESTROYED
|
|
252
|
+
// asynchronously (seen on the slower CI runners driving the real
|
|
253
|
+
// serve-fixture child, PR #15641). That race is the end-state close()
|
|
254
|
+
// wants, so it resolves; a real error still rejects.
|
|
255
|
+
this.child.stdin.end((error) => error === null || error === undefined || isBenignCloseRace(error)
|
|
256
|
+
? resolve()
|
|
257
|
+
: reject(error));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Escalate only as far as the host forces. Every stage ends at the child's
|
|
262
|
+
* REAL exit, so `exited` — and every classification derived from it — stays
|
|
263
|
+
* a function of the observed `(code, signal)` pair (INV-011). A signal is a
|
|
264
|
+
* way to reach an exit, never a substitute for observing one.
|
|
265
|
+
*/
|
|
266
|
+
async #awaitExitOrTerminate(deadline) {
|
|
267
|
+
if (await this.#beforeDeadline(this.#settled(), deadline))
|
|
268
|
+
return;
|
|
269
|
+
this.#signal("SIGTERM");
|
|
270
|
+
if (await this.#settledWithin(SIGTERM_GRACE_MS))
|
|
271
|
+
return;
|
|
272
|
+
// Past SIGTERM, drop our end of the pipe ourselves (PR #22819 round 3).
|
|
273
|
+
// Nothing else does, so a write the host never drained keeps `stdin`
|
|
274
|
+
// open — and the child's `close` event, which `exited` and the trailing
|
|
275
|
+
// EOF await both wait on, needs the stdio closed. Waiting for that write
|
|
276
|
+
// to error out on its own is the last unbounded stage; this removes it.
|
|
277
|
+
this.child.stdin.destroy();
|
|
278
|
+
this.#signal("SIGKILL");
|
|
279
|
+
await this.#settled();
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* One signal per escalation stage, delivered to the whole owned process
|
|
283
|
+
* group where this spawn created one (FR-017b, #22777). The child PID
|
|
284
|
+
* alone is not enough: a grandchild that inherited the host's stdout
|
|
285
|
+
* keeps the `close` event — which `exited` and every stage here wait
|
|
286
|
+
* on — from firing even after the child itself is gone, and it never
|
|
287
|
+
* receives a child-targeted signal at all. A group signal that fails for
|
|
288
|
+
* any reason (expected: `ESRCH`, the whole group already exited between
|
|
289
|
+
* stages) falls through to the direct path, which Node makes a no-op on
|
|
290
|
+
* an exited child — the ladder must never rethrow out of a stage.
|
|
291
|
+
*/
|
|
292
|
+
#signal(signal) {
|
|
293
|
+
const pid = this.child.pid;
|
|
294
|
+
if (this.#ownsProcessGroup && pid !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
process.kill(-pid, signal);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Fall through to the direct-child path, safe on every failure here.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.child.kill(signal);
|
|
304
|
+
}
|
|
305
|
+
/** True once `exited` settles either way — a rejection means no live child. */
|
|
306
|
+
async #settledWithin(budgetMs) {
|
|
307
|
+
return await this.#within(this.#settled(), budgetMs);
|
|
308
|
+
}
|
|
309
|
+
/** True if `work` settles before the one shared shutdown deadline fires. */
|
|
310
|
+
async #beforeDeadline(work, deadline) {
|
|
311
|
+
return await Promise.race([
|
|
312
|
+
work.then(() => true, () => true),
|
|
313
|
+
deadline.then(() => false),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
/** True if `work` settles inside `budgetMs`; false when the budget expires. */
|
|
317
|
+
async #within(work, budgetMs) {
|
|
318
|
+
// Deliberately ambient (NOT this.#makeShutdownDeadline): the SIGTERM grace
|
|
319
|
+
// window is outside the one shared shutdown budget the injected seam models.
|
|
320
|
+
const deadline = makeShutdownDeadline(budgetMs);
|
|
321
|
+
try {
|
|
322
|
+
return await this.#beforeDeadline(work, deadline.expired);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
deadline.clear();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* `exited` REJECTS when the spawn itself failed (ENOENT). There is no
|
|
330
|
+
* process to end and no exit to observe, so shutdown is complete; the
|
|
331
|
+
* rejection still reaches the caller through its own `await exited`
|
|
332
|
+
* (`MuseServeChild.close()`, `MspHandshake.close()`), unchanged.
|
|
333
|
+
*/
|
|
334
|
+
async #settled() {
|
|
335
|
+
await this.exited.catch(() => { });
|
|
336
|
+
}
|
|
337
|
+
async *#stdoutChunks() {
|
|
338
|
+
for await (const chunk of this.child.stdout)
|
|
339
|
+
yield String(chunk);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The exact options `MuseServeChild.spawn` hands to `child_process.spawn`,
|
|
344
|
+
* factored out so the spawn-options pin can assert on them without spawning.
|
|
345
|
+
* Exported from this MODULE only (never the package barrel) — the same
|
|
346
|
+
* module-only-test-seam precedent as `isBenignCloseRace`.
|
|
347
|
+
*/
|
|
348
|
+
export function buildSpawnOptions(options) {
|
|
349
|
+
return {
|
|
350
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
351
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
352
|
+
detached: options.detached,
|
|
353
|
+
// FR-45141-1: `muse serve` is never meant to be seen. Without this, a
|
|
354
|
+
// Windows host with no console of its own pops a console window for the
|
|
355
|
+
// life of the connection; the flag is a no-op on other platforms.
|
|
356
|
+
windowsHide: true,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** One owned MSP host process with SS2.11 diagnostics and total exit mapping. */
|
|
360
|
+
export class MuseServeChild {
|
|
361
|
+
#tail = new StderrTail();
|
|
362
|
+
#transport;
|
|
363
|
+
exit;
|
|
364
|
+
constructor(child, onStderr, shutdownTimeoutMs, ownsProcessGroup = false) {
|
|
365
|
+
this.#transport = new ChildStdioTransport(child, (chunk) => {
|
|
366
|
+
this.#tail.push(chunk);
|
|
367
|
+
onStderr?.(chunk);
|
|
368
|
+
}, shutdownTimeoutMs, ownsProcessGroup);
|
|
369
|
+
this.exit = this.#transport.exited.then((status) => classifyExit(status, this.#tail.lines()));
|
|
370
|
+
// A spawn error can reject before a consumer attaches its await. Mark the
|
|
371
|
+
// derived promise handled at birth without changing what later awaiters see.
|
|
372
|
+
this.exit.catch(() => { });
|
|
373
|
+
}
|
|
374
|
+
static spawn(options) {
|
|
375
|
+
// Validate BEFORE spawning: a throw from the transport constructor would
|
|
376
|
+
// otherwise leave the child it was meant to own already running.
|
|
377
|
+
if (options.shutdownTimeoutMs !== undefined)
|
|
378
|
+
assertShutdownTimeout(options.shutdownTimeoutMs);
|
|
379
|
+
// POSIX: own a whole process group, not just the child (FR-017b,
|
|
380
|
+
// #22777). `detached` makes the host the leader of a new group (and
|
|
381
|
+
// session), so escalation can end a grandchild holding the inherited
|
|
382
|
+
// stdout; stdio stays piped, so nothing else about ownership changes.
|
|
383
|
+
// Windows keeps the child-only path — group signalling via a negative
|
|
384
|
+
// PID is a POSIX primitive, and pretending otherwise would fake
|
|
385
|
+
// subtree containment there.
|
|
386
|
+
const ownsProcessGroup = process.platform !== "win32";
|
|
387
|
+
const spawnOptions = buildSpawnOptions({
|
|
388
|
+
cwd: options.cwd,
|
|
389
|
+
env: options.env,
|
|
390
|
+
detached: ownsProcessGroup,
|
|
391
|
+
});
|
|
392
|
+
const spawnChild = options.spawnFn ?? spawn;
|
|
393
|
+
return new MuseServeChild(spawnChild(options.museBin, [...(options.args ?? [])], spawnOptions), options.onStderr, options.shutdownTimeoutMs, ownsProcessGroup);
|
|
394
|
+
}
|
|
395
|
+
get stderrTail() {
|
|
396
|
+
return this.#tail.lines();
|
|
397
|
+
}
|
|
398
|
+
/** Module-private friend seam used by the handshake machine for this child. */
|
|
399
|
+
[ownedTransport]() {
|
|
400
|
+
return this.#transport;
|
|
401
|
+
}
|
|
402
|
+
async close() {
|
|
403
|
+
await this.#transport.close();
|
|
404
|
+
return await this.exit;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** The only public state before `initialized`: callers cannot send traffic. */
|
|
408
|
+
export class MspHandshake {
|
|
409
|
+
#connection;
|
|
410
|
+
child;
|
|
411
|
+
#transport;
|
|
412
|
+
#started = false;
|
|
413
|
+
#answered = false;
|
|
414
|
+
constructor(options) {
|
|
415
|
+
this.child = MuseServeChild.spawn({
|
|
416
|
+
museBin: options.command,
|
|
417
|
+
...(options.args === undefined ? {} : { args: options.args }),
|
|
418
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
419
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
420
|
+
...(options.onStderr === undefined ? {} : { onStderr: options.onStderr }),
|
|
421
|
+
...(options.shutdownTimeoutMs === undefined
|
|
422
|
+
? {}
|
|
423
|
+
: { shutdownTimeoutMs: options.shutdownTimeoutMs }),
|
|
424
|
+
});
|
|
425
|
+
this.#transport = this.child[ownedTransport]();
|
|
426
|
+
this.#connection = new Connection(this.#transport, options.connection);
|
|
427
|
+
// Make step 0 true on ALL THREE close surfaces, not only this class's:
|
|
428
|
+
// `child.close()` and `spawned.child.close()` are public, and without
|
|
429
|
+
// this they ended the host's input while a just-accepted frame was still
|
|
430
|
+
// queued on a microtask (PR #22819 round 4).
|
|
431
|
+
this.#transport[adoptFlushSource](() => this.#connection[submissionTail]());
|
|
432
|
+
}
|
|
433
|
+
get exited() {
|
|
434
|
+
return this.#transport.exited;
|
|
435
|
+
}
|
|
436
|
+
onServerRequest(handler) {
|
|
437
|
+
this.#connection.onServerRequest(handler);
|
|
438
|
+
}
|
|
439
|
+
onNotification(handler) {
|
|
440
|
+
this.#connection.onNotification(handler);
|
|
441
|
+
}
|
|
442
|
+
onProtocolError(handler) {
|
|
443
|
+
this.#connection.onProtocolError(handler);
|
|
444
|
+
}
|
|
445
|
+
async close() {
|
|
446
|
+
await this.#connection.close();
|
|
447
|
+
return await this.#transport.exited;
|
|
448
|
+
}
|
|
449
|
+
/** See {@link initializeAnswered}. */
|
|
450
|
+
[initializeAnswered]() {
|
|
451
|
+
return this.#answered;
|
|
452
|
+
}
|
|
453
|
+
async initialize(params) {
|
|
454
|
+
if (this.#started)
|
|
455
|
+
throw new ProtocolError("initialize may be sent only once per connection");
|
|
456
|
+
this.#started = true;
|
|
457
|
+
const { result: raw, line } = await this.#connection[requestWithFrame]("initialize", params);
|
|
458
|
+
this.#answered = true;
|
|
459
|
+
const result = raw;
|
|
460
|
+
if (typeof result.schema?.fingerprint !== "string") {
|
|
461
|
+
// The whole refused frame rides on the error, as on every other
|
|
462
|
+
// refusal: a host that answered with an unusable result was not lost,
|
|
463
|
+
// and the facade tells the two apart by it (#49912, #50588 review).
|
|
464
|
+
throw new ProtocolError("initialize result has no schema fingerprint", line);
|
|
465
|
+
}
|
|
466
|
+
const fingerprintWarning = checkServedFingerprint(result.schema.fingerprint);
|
|
467
|
+
this.#connection.notify("initialized");
|
|
468
|
+
await this.#connection.flush();
|
|
469
|
+
return new SpawnedMspConnection(this.#connection, this.#transport, this.child, result, fingerprintWarning);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** A fully initialized connection plus its owned process boundary. */
|
|
473
|
+
export class SpawnedMspConnection {
|
|
474
|
+
connection;
|
|
475
|
+
child;
|
|
476
|
+
initializeResult;
|
|
477
|
+
fingerprintWarning;
|
|
478
|
+
exited;
|
|
479
|
+
#transport;
|
|
480
|
+
constructor(connection, transport, child, initializeResult, fingerprintWarning) {
|
|
481
|
+
this.connection = connection;
|
|
482
|
+
this.#transport = transport;
|
|
483
|
+
this.child = child;
|
|
484
|
+
this.initializeResult = initializeResult;
|
|
485
|
+
this.fingerprintWarning = fingerprintWarning;
|
|
486
|
+
this.exited = transport.exited;
|
|
487
|
+
}
|
|
488
|
+
async close() {
|
|
489
|
+
await this.connection.close();
|
|
490
|
+
return await this.#transport.exited;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** Spawn one owned host and begin its SS1.4 handshake state machine. */
|
|
494
|
+
export function spawnMspConnection(options) {
|
|
495
|
+
return new MspHandshake(options);
|
|
496
|
+
}
|