@deque/axe-auth 1.4.0 → 1.5.0-next.7a5a136d
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 +43 -15
- package/credits.json +74 -0
- package/dist/cli/errors.d.ts +1 -1
- package/dist/cli/errors.js +1 -0
- package/dist/commands/run.d.ts +16 -0
- package/dist/commands/run.help.d.ts +2 -0
- package/dist/commands/run.help.js +41 -0
- package/dist/commands/run.js +108 -0
- package/dist/index.js +18 -2
- package/dist/oauth/keyringBinding.d.ts +1 -1
- package/dist/oauth/keyringBinding.js +3 -4
- package/dist/run/findFreePort.d.ts +17 -0
- package/dist/run/findFreePort.js +39 -0
- package/dist/run/pushToken.d.ts +10 -0
- package/dist/run/pushToken.js +35 -0
- package/dist/run/runSession.d.ts +62 -0
- package/dist/run/runSession.js +219 -0
- package/dist/run/supervise.d.ts +72 -0
- package/dist/run/supervise.js +234 -0
- package/dist/run/testUtils.d.ts +15 -0
- package/dist/run/testUtils.js +43 -0
- package/docs/architecture.md +63 -2
- package/docs/callback-server.md +9 -9
- package/package.json +6 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.default = runSession;
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
|
+
const errors_1 = require("../oauth/errors");
|
|
42
|
+
const getValidAccessToken_1 = require("../oauth/getValidAccessToken");
|
|
43
|
+
const tokenStore_1 = require("../oauth/tokenStore");
|
|
44
|
+
const errors_2 = require("../cli/errors");
|
|
45
|
+
const supervise_1 = __importStar(require("./supervise"));
|
|
46
|
+
const pushToken_1 = __importDefault(require("./pushToken"));
|
|
47
|
+
/**
|
|
48
|
+
* Cadence of the refresh-and-push loop. Must be shorter than the access-token
|
|
49
|
+
* TTL: each tick mints (which refreshes when the token is near expiry) and
|
|
50
|
+
* pushes any change, so the token only lapses if a whole TTL elapses between
|
|
51
|
+
* ticks. 60s suits typical Keycloak access-token lifetimes (minutes); a
|
|
52
|
+
* deployment issuing sub-minute tokens would need a shorter interval.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
|
|
55
|
+
/** Consecutive mid-session refresh failures before escalating from a per-tick line to one loud diagnostic. */
|
|
56
|
+
const CONSECUTIVE_FAILURE_WARNING_THRESHOLD = 3;
|
|
57
|
+
/**
|
|
58
|
+
* Extra time, on top of {@link MAX_TEARDOWN_MS}, given to `supervise`'s exit
|
|
59
|
+
* promise to settle after `teardown.abort()` before this function stops
|
|
60
|
+
* waiting on it. Covers the gap between the ladder's last signal firing and
|
|
61
|
+
* the child's `exit` event actually reaching us (process teardown, event-loop
|
|
62
|
+
* scheduling) — not more ladder; `supervise` itself is what bounds that.
|
|
63
|
+
*/
|
|
64
|
+
const TEARDOWN_SETTLE_BUFFER_MS = 500;
|
|
65
|
+
const realScheduler = (intervalMs, tick) => {
|
|
66
|
+
const timer = setInterval(tick, intervalMs);
|
|
67
|
+
timer.unref();
|
|
68
|
+
return () => clearInterval(timer);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Supervise the wrapped server command for a session: mint an initial access
|
|
72
|
+
* token, inject it plus the refresh secret and port into the child's
|
|
73
|
+
* environment, and keep the running server's token fresh by refreshing and
|
|
74
|
+
* pushing before expiry. Resolves with the child's exit code.
|
|
75
|
+
*
|
|
76
|
+
* Fails fast with a `NOT_AUTHENTICATED` {@link CLIError} if there are no stored
|
|
77
|
+
* credentials to mint from, and with `REFRESH_UNREACHABLE` if the very first
|
|
78
|
+
* push cannot reach the server (almost always a misconfigured port/secret) —
|
|
79
|
+
* exiting so the MCP client sees the failure rather than letting the session
|
|
80
|
+
* silently die at expiry. Once a push has succeeded, a later refresh failure is
|
|
81
|
+
* logged (the server keeps its last good token) rather than tearing the session
|
|
82
|
+
* down, with one louder diagnostic after {@link CONSECUTIVE_FAILURE_WARNING_THRESHOLD}
|
|
83
|
+
* consecutive failures.
|
|
84
|
+
*/
|
|
85
|
+
async function runSession(options) {
|
|
86
|
+
const { command, commandArgs, port, portWasAutoSelected = false, secret = (0, node_crypto_1.randomBytes)(24).toString("hex"), refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, getToken = getValidAccessToken_1.getValidAccessToken, tokenStore = new tokenStore_1.KeyringTokenStore(), push = pushToken_1.default, supervise = supervise_1.default, scheduler = realScheduler, env = process.env, stdin, stdout, stderr = process.stderr, onSignal, } = options;
|
|
87
|
+
const loaded = await tokenStore.load();
|
|
88
|
+
const coordinates = {
|
|
89
|
+
issuerURL: loaded.ok ? loaded.entry.issuerURL : "",
|
|
90
|
+
clientId: loaded.ok ? loaded.entry.clientId : "",
|
|
91
|
+
allowInsecureIssuer: loaded.ok ? loaded.entry.allowInsecureIssuer : false,
|
|
92
|
+
};
|
|
93
|
+
// No `loadedEntry`: each call re-reads the store so it picks up the rotated
|
|
94
|
+
// refresh token from the previous refresh instead of replaying a stale one.
|
|
95
|
+
const mint = async () => {
|
|
96
|
+
try {
|
|
97
|
+
return await getToken({ ...coordinates, tokenStore });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof errors_1.OAuthFlowError && err.code === "NOT_AUTHENTICATED") {
|
|
101
|
+
throw new errors_2.CLIError("NOT_AUTHENTICATED", err.message);
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const initialToken = await mint();
|
|
107
|
+
const childEnv = {
|
|
108
|
+
...env,
|
|
109
|
+
AXE_ACCESS_TOKEN: initialToken,
|
|
110
|
+
AXE_TOKEN_REFRESH_PORT: String(port),
|
|
111
|
+
AXE_TOKEN_REFRESH_SECRET: secret,
|
|
112
|
+
// Only a container needs a non-loopback bind, and a container always pins
|
|
113
|
+
// its port. On an auto-picked port a stale `0.0.0.0` inherited from the
|
|
114
|
+
// environment would expose the token endpoint off-host, so pin loopback.
|
|
115
|
+
...(portWasAutoSelected ? { AXE_TOKEN_REFRESH_HOST: "127.0.0.1" } : {}),
|
|
116
|
+
};
|
|
117
|
+
let lastPushed = initialToken;
|
|
118
|
+
let pushedSuccessfully = false;
|
|
119
|
+
let consecutiveFailures = 0;
|
|
120
|
+
let refreshing = false;
|
|
121
|
+
// Lets a fatal first-push failure interrupt the awaited `supervise` below.
|
|
122
|
+
let rejectFatal;
|
|
123
|
+
const fatal = new Promise((_, reject) => {
|
|
124
|
+
rejectFatal = reject;
|
|
125
|
+
});
|
|
126
|
+
// When `fatal` wins the race below, nothing else would ever tear the child
|
|
127
|
+
// down: this process exits while the wrapped server keeps the refresh port.
|
|
128
|
+
const teardown = new AbortController();
|
|
129
|
+
// A recoverable refresh failure: log it, and once failures pile up emit one
|
|
130
|
+
// louder line (they will keep failing every tick otherwise). The session
|
|
131
|
+
// stays alive on its last good token.
|
|
132
|
+
const noteRecoverableFailure = (message) => {
|
|
133
|
+
stderr.write(`axe-auth run: token refresh failed: ${message}\n`);
|
|
134
|
+
consecutiveFailures += 1;
|
|
135
|
+
if (consecutiveFailures === CONSECUTIVE_FAILURE_WARNING_THRESHOLD) {
|
|
136
|
+
stderr.write(`axe-auth run: token refresh has failed ${consecutiveFailures} times in a row; the server's access token will expire and calls will start failing — check that the refresh listener is reachable\n`);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const cancel = scheduler(refreshIntervalMs, () => {
|
|
140
|
+
if (refreshing) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
refreshing = true;
|
|
144
|
+
void (async () => {
|
|
145
|
+
try {
|
|
146
|
+
let fresh;
|
|
147
|
+
try {
|
|
148
|
+
fresh = await mint();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Mint failure (e.g. a transient Keycloak blip): the injected token
|
|
152
|
+
// may still be valid, so keep the session alive rather than exiting.
|
|
153
|
+
noteRecoverableFailure((0, errors_2.describeError)(err));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (fresh === lastPushed) {
|
|
157
|
+
consecutiveFailures = 0;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await push({ port, secret, accessToken: fresh });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (!pushedSuccessfully && !portWasAutoSelected) {
|
|
165
|
+
// The first push never reached a port the user chose — almost
|
|
166
|
+
// always a misconfigured port/secret. Fail so the client sees it.
|
|
167
|
+
rejectFatal(new errors_2.CLIError("REFRESH_UNREACHABLE", `token refresh could not reach the server on port ${port} (${(0, errors_2.describeError)(err)}); check the refresh port and secret`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
noteRecoverableFailure((0, errors_2.describeError)(err));
|
|
171
|
+
if (!pushedSuccessfully) {
|
|
172
|
+
// Nothing ever answered on a port we chose, so the server did not
|
|
173
|
+
// get it. Stop rather than re-offering a token every interval to
|
|
174
|
+
// whatever else may hold it (#1028). The session keeps running;
|
|
175
|
+
// it just has no refresh.
|
|
176
|
+
cancel();
|
|
177
|
+
stderr.write("axe-auth run: no token-refresh listener answered on the port this session picked; continuing without refresh\n");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
lastPushed = fresh;
|
|
182
|
+
pushedSuccessfully = true;
|
|
183
|
+
consecutiveFailures = 0;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
refreshing = false;
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
});
|
|
190
|
+
// Held separately so the `finally` can await it: if `fatal` wins the race,
|
|
191
|
+
// this is still pending with the wrapped server mid-teardown.
|
|
192
|
+
const superviseResult = supervise({
|
|
193
|
+
command,
|
|
194
|
+
args: commandArgs,
|
|
195
|
+
env: childEnv,
|
|
196
|
+
stdin,
|
|
197
|
+
stdout,
|
|
198
|
+
stderr,
|
|
199
|
+
onSignal,
|
|
200
|
+
signal: teardown.signal,
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
return await Promise.race([superviseResult, fatal]);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
cancel();
|
|
207
|
+
teardown.abort();
|
|
208
|
+
// The entrypoint calls `process.exit` with no macrotask in between, so
|
|
209
|
+
// without this the ladder's timers never run and the child is orphaned.
|
|
210
|
+
// Bounded so a `supervise` bug cannot hang the CLI.
|
|
211
|
+
await Promise.race([
|
|
212
|
+
superviseResult.catch(() => { }),
|
|
213
|
+
new Promise((resolve) => {
|
|
214
|
+
const settleTimer = setTimeout(resolve, supervise_1.MAX_TEARDOWN_MS + TEARDOWN_SETTLE_BUFFER_MS);
|
|
215
|
+
settleTimer.unref();
|
|
216
|
+
}),
|
|
217
|
+
]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
/**
|
|
3
|
+
* Grace the wrapped server gets before `SIGKILL`. Must exceed that server's
|
|
4
|
+
* own teardown budget or its graceful path never completes: axe-mcp-server
|
|
5
|
+
* publishes {@link SERVER_TEARDOWN_BUDGET_MS}, so this leaves a second of
|
|
6
|
+
* margin. Raising that budget means raising this.
|
|
7
|
+
*
|
|
8
|
+
* TODO(nayanrajDQ): measure a live Chromium teardown against this grace.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TEARDOWN_GRACE_MS = 4000;
|
|
11
|
+
/**
|
|
12
|
+
* The budget axe-mcp-server publishes as `MAX_TEARDOWN_BUDGET_MS`. Restated
|
|
13
|
+
* here because the two are separately released packages with no shared code;
|
|
14
|
+
* a test pins each side so raising one without the other fails loudly.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SERVER_TEARDOWN_BUDGET_MS = 3000;
|
|
17
|
+
/** Each rung's signal and how long to wait *before* sending it. */
|
|
18
|
+
export type TeardownLadder = ReadonlyArray<{
|
|
19
|
+
signal: NodeJS.Signals;
|
|
20
|
+
delayBeforeMs: number;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Upper bound on the ladder once teardown starts. Exported so `runSession`
|
|
24
|
+
* bounds its wait on the ladder's arithmetic, not a constant of its own.
|
|
25
|
+
*/
|
|
26
|
+
export declare const MAX_TEARDOWN_MS: number;
|
|
27
|
+
/** Options for {@link supervise}. */
|
|
28
|
+
export interface SuperviseOptions {
|
|
29
|
+
/** Executable to run (e.g. `docker`, `npx`). */
|
|
30
|
+
command: string;
|
|
31
|
+
/** Arguments passed to the executable. */
|
|
32
|
+
args: string[];
|
|
33
|
+
/** Environment for the child. Defaults to `process.env`. */
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
/** Stream feeding the child's stdin. Defaults to `process.stdin`. */
|
|
36
|
+
stdin?: Readable;
|
|
37
|
+
/** Stream the child's stdout is written to. Defaults to `process.stdout`. */
|
|
38
|
+
stdout?: Writable;
|
|
39
|
+
/** Stream the child's stderr is written to. Defaults to `process.stderr`. */
|
|
40
|
+
stderr?: Writable;
|
|
41
|
+
/** Registers a termination-signal handler. Injectable for tests; defaults to `process.on`. */
|
|
42
|
+
onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
|
|
43
|
+
/** Registers a parent-death watchdog. Injectable for tests; defaults to polling {@link SuperviseOptions.readParentPID}. */
|
|
44
|
+
onParentExit?: (handler: () => void) => void;
|
|
45
|
+
/** Reads the launching process's pid. Injectable for tests; defaults to `process.ppid`. */
|
|
46
|
+
readParentPID?: () => number;
|
|
47
|
+
/** Tears the child down when aborted, e.g. because the caller hit a fatal error. */
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/**
|
|
50
|
+
* Signal-and-wait rungs teardown escalates through. Injectable so tests can
|
|
51
|
+
* exercise an escalation without sleeping out the shipped grace; defaults to
|
|
52
|
+
* the real ladder, which one test keeps covered at its true timings.
|
|
53
|
+
*/
|
|
54
|
+
ladder?: TeardownLadder;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Spawn `command` and transparently bridge this process's stdio to it: the
|
|
58
|
+
* parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
|
|
59
|
+
* reaches the wrapped server untouched), the child's stdout and stderr flow
|
|
60
|
+
* back out, termination signals are forwarded, and the child's exit is
|
|
61
|
+
* propagated. Resolves with the child's exit code, or `128 + signal` when the
|
|
62
|
+
* child is killed by a signal (shell convention); rejects only if the child
|
|
63
|
+
* fails to spawn.
|
|
64
|
+
*
|
|
65
|
+
* stdin EOF, a forwarded signal, {@link SuperviseOptions.signal}, and the
|
|
66
|
+
* launching process vanishing each put the child on a bounded teardown ladder.
|
|
67
|
+
*
|
|
68
|
+
* A client that leaks stdin open without signalling or exiting fires none of
|
|
69
|
+
* those. The wrapped server catches that itself by probing the client, and its
|
|
70
|
+
* exit unwinds this supervisor.
|
|
71
|
+
*/
|
|
72
|
+
export default function supervise(options: SuperviseOptions): Promise<number>;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_TEARDOWN_MS = exports.SERVER_TEARDOWN_BUDGET_MS = exports.TEARDOWN_GRACE_MS = void 0;
|
|
7
|
+
exports.default = supervise;
|
|
8
|
+
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
/** Signals forwarded from this process to the supervised child. */
|
|
12
|
+
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
13
|
+
/**
|
|
14
|
+
* Grace the wrapped server gets before `SIGKILL`. Must exceed that server's
|
|
15
|
+
* own teardown budget or its graceful path never completes: axe-mcp-server
|
|
16
|
+
* publishes {@link SERVER_TEARDOWN_BUDGET_MS}, so this leaves a second of
|
|
17
|
+
* margin. Raising that budget means raising this.
|
|
18
|
+
*
|
|
19
|
+
* TODO(nayanrajDQ): measure a live Chromium teardown against this grace.
|
|
20
|
+
*/
|
|
21
|
+
exports.TEARDOWN_GRACE_MS = 4_000;
|
|
22
|
+
/**
|
|
23
|
+
* The budget axe-mcp-server publishes as `MAX_TEARDOWN_BUDGET_MS`. Restated
|
|
24
|
+
* here because the two are separately released packages with no shared code;
|
|
25
|
+
* a test pins each side so raising one without the other fails loudly.
|
|
26
|
+
*/
|
|
27
|
+
exports.SERVER_TEARDOWN_BUDGET_MS = 3_000;
|
|
28
|
+
/**
|
|
29
|
+
* The shipped POSIX ladder. On a forwarded signal the caller's signal is the
|
|
30
|
+
* polite rung, so only the SIGKILL wait runs.
|
|
31
|
+
*/
|
|
32
|
+
const TEARDOWN_LADDER = [
|
|
33
|
+
{ signal: "SIGTERM", delayBeforeMs: 2_000 },
|
|
34
|
+
{ signal: "SIGKILL", delayBeforeMs: exports.TEARDOWN_GRACE_MS },
|
|
35
|
+
];
|
|
36
|
+
/**
|
|
37
|
+
* Windows has no signalable process group and no polite signal, so the whole
|
|
38
|
+
* ladder is one rung: close the child's stdin, wait out the same grace, then
|
|
39
|
+
* force-kill the tree. Sharing {@link TEARDOWN_LADDER} would force-kill at its
|
|
40
|
+
* first rung, giving the server less grace than it publishes.
|
|
41
|
+
*/
|
|
42
|
+
const WINDOWS_TEARDOWN_LADDER = [
|
|
43
|
+
{ signal: "SIGKILL", delayBeforeMs: exports.TEARDOWN_GRACE_MS },
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Upper bound on the ladder once teardown starts. Exported so `runSession`
|
|
47
|
+
* bounds its wait on the ladder's arithmetic, not a constant of its own.
|
|
48
|
+
*/
|
|
49
|
+
exports.MAX_TEARDOWN_MS = TEARDOWN_LADDER.reduce((total, rung) => total + rung.delayBeforeMs, 0);
|
|
50
|
+
/**
|
|
51
|
+
* How often the watchdog samples `process.ppid`. A launcher that vanishes
|
|
52
|
+
* without closing stdin or signalling is only observable by polling.
|
|
53
|
+
*/
|
|
54
|
+
const PARENT_POLL_INTERVAL_MS = 1_000;
|
|
55
|
+
/**
|
|
56
|
+
* Spawn `command` and transparently bridge this process's stdio to it: the
|
|
57
|
+
* parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
|
|
58
|
+
* reaches the wrapped server untouched), the child's stdout and stderr flow
|
|
59
|
+
* back out, termination signals are forwarded, and the child's exit is
|
|
60
|
+
* propagated. Resolves with the child's exit code, or `128 + signal` when the
|
|
61
|
+
* child is killed by a signal (shell convention); rejects only if the child
|
|
62
|
+
* fails to spawn.
|
|
63
|
+
*
|
|
64
|
+
* stdin EOF, a forwarded signal, {@link SuperviseOptions.signal}, and the
|
|
65
|
+
* launching process vanishing each put the child on a bounded teardown ladder.
|
|
66
|
+
*
|
|
67
|
+
* A client that leaks stdin open without signalling or exiting fires none of
|
|
68
|
+
* those. The wrapped server catches that itself by probing the client, and its
|
|
69
|
+
* exit unwinds this supervisor.
|
|
70
|
+
*/
|
|
71
|
+
function supervise(options) {
|
|
72
|
+
const { command, args, env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, onSignal = (signal, handler) => {
|
|
73
|
+
process.on(signal, handler);
|
|
74
|
+
}, readParentPID = () => process.ppid, onParentExit = (handler) => {
|
|
75
|
+
const parentPID = readParentPID();
|
|
76
|
+
const timer = setInterval(() => {
|
|
77
|
+
// Reparenting is the only portable signal that the parent died.
|
|
78
|
+
if (readParentPID() !== parentPID) {
|
|
79
|
+
handler();
|
|
80
|
+
}
|
|
81
|
+
}, PARENT_POLL_INTERVAL_MS);
|
|
82
|
+
timer.unref();
|
|
83
|
+
}, signal: abortSignal, ladder = process.platform === "win32"
|
|
84
|
+
? WINDOWS_TEARDOWN_LADDER
|
|
85
|
+
: TEARDOWN_LADDER, } = options;
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const child = (0, cross_spawn_1.default)(command, args, {
|
|
88
|
+
env,
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
+
// POSIX only: own process group so a kill reaches the whole tree, since
|
|
91
|
+
// a dead child's children reparent away and cannot be walked after. The
|
|
92
|
+
// pipes are untouched, so EOF still arrives if this process is SIGKILLed.
|
|
93
|
+
// Windows gets the same reach via `taskkill /T`; see `signalTree`.
|
|
94
|
+
detached: process.platform !== "win32",
|
|
95
|
+
});
|
|
96
|
+
let exited = false;
|
|
97
|
+
let tearingDown = false;
|
|
98
|
+
let rungTimer;
|
|
99
|
+
/**
|
|
100
|
+
* Signal the child's whole process tree.
|
|
101
|
+
*
|
|
102
|
+
* TODO(stephenmathieson): a container runtime's container is a child of
|
|
103
|
+
* the daemon, so `SIGKILL` reaches only the client and can leave the
|
|
104
|
+
* container running on its published port (#1027).
|
|
105
|
+
*
|
|
106
|
+
* Windows has no signalable process group and `process.kill` there maps
|
|
107
|
+
* every signal onto `TerminateProcess`, which kills the direct child and
|
|
108
|
+
* orphans the grandchild holding the port. `taskkill /T` walks the tree.
|
|
109
|
+
*
|
|
110
|
+
* Both rungs pass `/F`: Windows has no graceful termination to ask for,
|
|
111
|
+
* and `/T` alone leaves a console process running.
|
|
112
|
+
*/
|
|
113
|
+
const signalTree = (signal) => {
|
|
114
|
+
if (exited || child.pid === undefined) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (process.platform === "win32") {
|
|
118
|
+
// Absolute path: Windows resolution searches the working directory
|
|
119
|
+
// first, and we launch from the user's project folder, so a planted
|
|
120
|
+
// `taskkill.exe` would win.
|
|
121
|
+
const taskkill = process.env.SystemRoot
|
|
122
|
+
? (0, node_path_1.join)(process.env.SystemRoot, "System32", "taskkill.exe")
|
|
123
|
+
: "taskkill";
|
|
124
|
+
const killer = (0, cross_spawn_1.default)(taskkill, ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
125
|
+
killer.on("error", () => { });
|
|
126
|
+
killer.unref();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
process.kill(-child.pid, signal);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Already reaped, or the signal means nothing on this platform.
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** Walk the remaining rungs of the ladder, stopping as soon as the child exits. */
|
|
137
|
+
const escalate = (rungs) => {
|
|
138
|
+
const [next, ...rest] = rungs;
|
|
139
|
+
if (exited || next === undefined) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
rungTimer = setTimeout(() => {
|
|
143
|
+
if (exited) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// No pid means spawn failed after the ladder was armed. Nothing to
|
|
147
|
+
// kill, so don't claim one happened.
|
|
148
|
+
if (next.signal === "SIGKILL" && child.pid !== undefined) {
|
|
149
|
+
stderr.write(process.platform === "win32"
|
|
150
|
+
? "axe-auth run: wrapped server did not stop when asked; killing its process tree\n"
|
|
151
|
+
: "axe-auth run: wrapped server did not stop when asked; killing its process group\n");
|
|
152
|
+
}
|
|
153
|
+
signalTree(next.signal);
|
|
154
|
+
escalate(rest);
|
|
155
|
+
}, next.delayBeforeMs);
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Start the teardown ladder. `asked` is the signal already sent for the
|
|
159
|
+
* caller, or `undefined` on the stdin-EOF path where the closed pipe is
|
|
160
|
+
* itself the polite request.
|
|
161
|
+
*/
|
|
162
|
+
const beginTeardown = (asked) => {
|
|
163
|
+
if (exited || tearingDown) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
tearingDown = true;
|
|
167
|
+
if (process.platform === "win32") {
|
|
168
|
+
// No polite signal exists here: `signalTree` is always a forced tree
|
|
169
|
+
// kill, so forwarding the caller's signal would skip the grace
|
|
170
|
+
// entirely. The closed pipe is the only graceful rung, and it is what
|
|
171
|
+
// the wrapped server shuts down on.
|
|
172
|
+
child.stdin?.end();
|
|
173
|
+
escalate(ladder);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (asked) {
|
|
177
|
+
// The caller's signal was the polite rung; only harder ones remain.
|
|
178
|
+
signalTree(asked);
|
|
179
|
+
escalate(ladder.slice(1));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// The closed pipe was the polite request; run the full ladder.
|
|
183
|
+
escalate(ladder);
|
|
184
|
+
};
|
|
185
|
+
child.once("error", reject);
|
|
186
|
+
if (child.stdin) {
|
|
187
|
+
stdin.pipe(child.stdin);
|
|
188
|
+
// Writing to a child that already exited throws EPIPE otherwise.
|
|
189
|
+
child.stdin.on("error", () => { });
|
|
190
|
+
}
|
|
191
|
+
// The server shuts down on the closed pipe, but may not obey.
|
|
192
|
+
stdin.once("end", () => {
|
|
193
|
+
beginTeardown(undefined);
|
|
194
|
+
});
|
|
195
|
+
// `end: false` so the child cannot close the parent's shared streams.
|
|
196
|
+
child.stdout?.pipe(stdout, { end: false });
|
|
197
|
+
child.stderr?.pipe(stderr, { end: false });
|
|
198
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
199
|
+
onSignal(signal, () => {
|
|
200
|
+
beginTeardown(signal);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
onParentExit(() => {
|
|
204
|
+
if (exited || tearingDown) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
stderr.write("axe-auth run: the process that launched this session is gone; shutting down the wrapped server\n");
|
|
208
|
+
beginTeardown("SIGTERM");
|
|
209
|
+
});
|
|
210
|
+
// An already-aborted signal fires no event, so subscribing alone would
|
|
211
|
+
// spawn the child and never tear it down.
|
|
212
|
+
if (abortSignal?.aborted) {
|
|
213
|
+
beginTeardown("SIGTERM");
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
abortSignal?.addEventListener("abort", () => {
|
|
217
|
+
beginTeardown("SIGTERM");
|
|
218
|
+
}, { once: true });
|
|
219
|
+
}
|
|
220
|
+
child.once("exit", (code, signal) => {
|
|
221
|
+
exited = true;
|
|
222
|
+
clearTimeout(rungTimer);
|
|
223
|
+
if (child.stdin) {
|
|
224
|
+
stdin.unpipe(child.stdin);
|
|
225
|
+
}
|
|
226
|
+
if (code !== null) {
|
|
227
|
+
resolve(code);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
resolve(128 + (node_os_1.constants.signals[signal ?? "SIGTERM"] ?? 0));
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a test waits on a teardown before calling it hung. Derived, so
|
|
3
|
+
* moving a ladder rung cannot turn these assertions into flakes.
|
|
4
|
+
*/
|
|
5
|
+
export declare const MAX_TEARDOWN_WAIT_MS: number;
|
|
6
|
+
/** Whether `pid` still exists (a reaped process's signal 0 throws ESRCH). */
|
|
7
|
+
export declare function alive(pid: number): boolean;
|
|
8
|
+
/** Poll until `predicate` holds or time runs out. */
|
|
9
|
+
export declare function waitFor(predicate: () => boolean, timeoutMs?: number): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* The resolved value, or `"hung"` if `work` was still pending after `ms`.
|
|
12
|
+
* A bounded assertion is the point: an unbounded `await` on a supervisor that
|
|
13
|
+
* never tears down just hangs the test run instead of failing it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function settleWithin<T>(work: Promise<T>, ms?: number): Promise<T | "hung">;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_TEARDOWN_WAIT_MS = void 0;
|
|
4
|
+
exports.alive = alive;
|
|
5
|
+
exports.waitFor = waitFor;
|
|
6
|
+
exports.settleWithin = settleWithin;
|
|
7
|
+
const supervise_1 = require("./supervise");
|
|
8
|
+
/**
|
|
9
|
+
* How long a test waits on a teardown before calling it hung. Derived, so
|
|
10
|
+
* moving a ladder rung cannot turn these assertions into flakes.
|
|
11
|
+
*/
|
|
12
|
+
exports.MAX_TEARDOWN_WAIT_MS = supervise_1.MAX_TEARDOWN_MS + 2_000;
|
|
13
|
+
/** Whether `pid` still exists (a reaped process's signal 0 throws ESRCH). */
|
|
14
|
+
function alive(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Poll until `predicate` holds or time runs out. */
|
|
24
|
+
async function waitFor(predicate, timeoutMs = exports.MAX_TEARDOWN_WAIT_MS) {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
while (!predicate()) {
|
|
27
|
+
if (Date.now() - start > timeoutMs) {
|
|
28
|
+
throw new Error("waitFor timed out");
|
|
29
|
+
}
|
|
30
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The resolved value, or `"hung"` if `work` was still pending after `ms`.
|
|
35
|
+
* A bounded assertion is the point: an unbounded `await` on a supervisor that
|
|
36
|
+
* never tears down just hangs the test run instead of failing it.
|
|
37
|
+
*/
|
|
38
|
+
async function settleWithin(work, ms = exports.MAX_TEARDOWN_WAIT_MS) {
|
|
39
|
+
return await Promise.race([
|
|
40
|
+
work,
|
|
41
|
+
new Promise((r) => setTimeout(() => r("hung"), ms).unref()),
|
|
42
|
+
]);
|
|
43
|
+
}
|