@deque/axe-auth 1.5.0-rc.bbbeb999 → 1.6.0-next.06cbb604
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 +3 -3
- package/dist/commands/run.help.d.ts +1 -1
- package/dist/commands/run.help.js +8 -3
- package/dist/commands/run.js +29 -6
- package/dist/run/findFreePort.d.ts +17 -0
- package/dist/run/findFreePort.js +39 -0
- package/dist/run/pushToken.js +12 -1
- package/dist/run/runSession.d.ts +6 -0
- package/dist/run/runSession.js +86 -19
- package/dist/run/supervise.d.ts +43 -2
- package/dist/run/supervise.js +182 -11
- package/dist/run/testUtils.d.ts +15 -0
- package/dist/run/testUtils.js +43 -0
- package/docs/architecture.md +25 -4
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ axe-auth logout
|
|
|
72
72
|
|
|
73
73
|
### Long-running sessions
|
|
74
74
|
|
|
75
|
-
Access tokens are short-lived, so a session that outlives the token's TTL (an agent driving the MCP server for hours, say) would otherwise start seeing auth failures. `axe-auth run` handles this automatically: it launches and supervises the server, then keeps its access token fresh for the whole session with no restart and no manual step. Point your MCP client at `axe-auth run` as the server command; it manages the server process's lifetime like any stdio server. Your refresh token never leaves the machine; only short-lived access tokens are sent to the server.
|
|
75
|
+
Access tokens are short-lived, so a session that outlives the token's TTL (an agent driving the MCP server for hours, say) would otherwise start seeing auth failures. `axe-auth run` handles this automatically: it launches and supervises the server, then keeps its access token fresh for the whole session with no restart and no manual step. Point your MCP client at `axe-auth run` as the server command; it manages the server process's lifetime like any stdio server. It also shuts the server down when the session ends: on stdin close, on a termination signal, and when the launching process disappears. The server itself exits when the client stops answering its liveness probes. Each session takes a fresh refresh port from the OS unless you pin one with `--port` or `AXE_TOKEN_REFRESH_PORT`, so a server left behind by an earlier session cannot hold the port this one needs. Pin one for containers, which only reach a port you publish: `run` detects `docker`, `podman`, and `nerdctl` and refuses to guess, but any other runtime or a wrapper script is yours to pin. Your refresh token never leaves the machine; only short-lived access tokens are sent to the server.
|
|
76
76
|
|
|
77
77
|
The server just needs token refresh enabled on a port; `run` supplies the token and secret. Under Docker, publish the port on host loopback, forward the auth vars, and bind the listener to `0.0.0.0` inside the container so the published port can reach it:
|
|
78
78
|
|
|
@@ -92,10 +92,10 @@ AXE_TOKEN_REFRESH_PORT=9223 npx @deque/axe-auth run -- \
|
|
|
92
92
|
Under npm the wrapped process inherits the values directly and the listener stays on loopback:
|
|
93
93
|
|
|
94
94
|
```sh
|
|
95
|
-
|
|
95
|
+
npx @deque/axe-auth run -- npx axe-mcp-server
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
`run` picks a free loopback port per session; pin one with `AXE_TOKEN_REFRESH_PORT` or `--port` only for containers, which can reach only a port you publish. `run` generates the shared secret unless you pin one with `AXE_TOKEN_REFRESH_SECRET` / `--secret`. The secret authenticates the push; the server's listener binds loopback by default and never starts without a secret.
|
|
99
99
|
|
|
100
100
|
## Architecture
|
|
101
101
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Help text for `axe-auth run --help`. */
|
|
2
|
-
export declare const HELP_RUN = "npx @deque/axe-auth run -- <server launch command>\n\nLaunch and supervise the axe MCP server so it keeps a valid OAuth access\ntoken for the whole session, refreshing before expiry without a restart. Use\nthis as your MCP client's server command; the client manages its lifetime like\nany stdio server. Everything after `--` is the command it launches.\n\nUsage:\n npx @deque/axe-auth run [--port <port>] [--secret <secret>] -- <command> [args...]\n\n The wrapped server must be started with token refresh on a loopback port.\n Under Docker, publish it with `-p 127.0.0.1:<port>:<port>`, forward the auth\n vars with `-e AXE_ACCESS_TOKEN -e AXE_TOKEN_REFRESH_PORT -e AXE_TOKEN_REFRESH_SECRET`,\n and set `-e AXE_TOKEN_REFRESH_HOST=0.0.0.0` so the published port can reach the\n listener; `run` supplies the token/port/secret values. Under npm the wrapped\n node process inherits them directly.\n\nOptions:\n --port <port> Loopback port the server's refresh listener uses.\n Defaults to AXE_TOKEN_REFRESH_PORT.\n --secret <secret> Shared secret for the refresh channel. Defaults to\n AXE_TOKEN_REFRESH_SECRET, otherwise a generated one.\n Prefer the env var: a value passed here is visible to other\n local users via the process list.\n -h, --help Show this help.\n\nExit codes:\n 0 The wrapped server exited normally.\n 1 Not authenticated; run `npx @deque/axe-auth login` first.\n 2 Configuration or connectivity error (
|
|
2
|
+
export declare const HELP_RUN = "npx @deque/axe-auth run -- <server launch command>\n\nLaunch and supervise the axe MCP server so it keeps a valid OAuth access\ntoken for the whole session, refreshing before expiry without a restart. Use\nthis as your MCP client's server command; the client manages its lifetime like\nany stdio server. Everything after `--` is the command it launches.\n\nUsage:\n npx @deque/axe-auth run [--port <port>] [--secret <secret>] -- <command> [args...]\n\n The wrapped server must be started with token refresh on a loopback port.\n Under Docker, publish it with `-p 127.0.0.1:<port>:<port>`, forward the auth\n vars with `-e AXE_ACCESS_TOKEN -e AXE_TOKEN_REFRESH_PORT -e AXE_TOKEN_REFRESH_SECRET`,\n and set `-e AXE_TOKEN_REFRESH_HOST=0.0.0.0` so the published port can reach the\n listener; `run` supplies the token/port/secret values. Under npm the wrapped\n node process inherits them directly.\n\nOptions:\n --port <port> Loopback port the server's refresh listener uses.\n Defaults to AXE_TOKEN_REFRESH_PORT, otherwise a free\n port chosen per session. Pin one for a container,\n which can only reach a published port: docker,\n podman, and nerdctl are detected and refused\n without it; pin it yourself for anything else.\n --secret <secret> Shared secret for the refresh channel. Defaults to\n AXE_TOKEN_REFRESH_SECRET, otherwise a generated one.\n Prefer the env var: a value passed here is visible to other\n local users via the process list.\n -h, --help Show this help.\n\nExit codes:\n 0 The wrapped server exited normally.\n 1 Not authenticated; run `npx @deque/axe-auth login` first.\n 2 Configuration or connectivity error (an invalid port, a container\n command with no port pinned, no command after `--`, or a pinned\n refresh port the server could not be reached on).\n <n> Otherwise, the wrapped server's own exit code.";
|
|
@@ -21,7 +21,11 @@ Usage:
|
|
|
21
21
|
|
|
22
22
|
Options:
|
|
23
23
|
--port <port> Loopback port the server's refresh listener uses.
|
|
24
|
-
Defaults to AXE_TOKEN_REFRESH_PORT
|
|
24
|
+
Defaults to AXE_TOKEN_REFRESH_PORT, otherwise a free
|
|
25
|
+
port chosen per session. Pin one for a container,
|
|
26
|
+
which can only reach a published port: docker,
|
|
27
|
+
podman, and nerdctl are detected and refused
|
|
28
|
+
without it; pin it yourself for anything else.
|
|
25
29
|
--secret <secret> Shared secret for the refresh channel. Defaults to
|
|
26
30
|
AXE_TOKEN_REFRESH_SECRET, otherwise a generated one.
|
|
27
31
|
Prefer the env var: a value passed here is visible to other
|
|
@@ -31,6 +35,7 @@ Options:
|
|
|
31
35
|
Exit codes:
|
|
32
36
|
0 The wrapped server exited normally.
|
|
33
37
|
1 Not authenticated; run \`npx @deque/axe-auth login\` first.
|
|
34
|
-
2 Configuration or connectivity error (
|
|
35
|
-
|
|
38
|
+
2 Configuration or connectivity error (an invalid port, a container
|
|
39
|
+
command with no port pinned, no command after \`--\`, or a pinned
|
|
40
|
+
refresh port the server could not be reached on).
|
|
36
41
|
<n> Otherwise, the wrapped server's own exit code.`;
|
package/dist/commands/run.js
CHANGED
|
@@ -6,8 +6,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.dispatchRun = dispatchRun;
|
|
7
7
|
const node_util_1 = require("node:util");
|
|
8
8
|
const runSession_1 = __importDefault(require("../run/runSession"));
|
|
9
|
+
const findFreePort_1 = __importDefault(require("../run/findFreePort"));
|
|
9
10
|
const run_help_1 = require("./run.help");
|
|
10
11
|
const errors_1 = require("../cli/errors");
|
|
12
|
+
/** Container runtimes whose child cannot reach a host port it was not told to publish. */
|
|
13
|
+
const CONTAINER_COMMANDS = new Set(["docker", "podman", "nerdctl"]);
|
|
14
|
+
/** Whether `command` launches a container runtime rather than a local process. */
|
|
15
|
+
function isContainerCommand(command) {
|
|
16
|
+
if (!command) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const name = command.split(/[/\\]/).pop() ?? command;
|
|
20
|
+
return CONTAINER_COMMANDS.has(name.replace(/\.exe$/i, "").toLowerCase());
|
|
21
|
+
}
|
|
11
22
|
/**
|
|
12
23
|
* `axe-auth run [--port] [--secret] -- <command> [args...]`. Unlike the other
|
|
13
24
|
* verbs, everything after `--` is the wrapped server command, so this bypasses
|
|
@@ -45,16 +56,27 @@ async function dispatchRun(rest, deps) {
|
|
|
45
56
|
deps.stderr.write("axe-auth run needs a command after `--`, e.g. `npx @deque/axe-auth run -- docker run ... axe-mcp-server`\n");
|
|
46
57
|
return 2;
|
|
47
58
|
}
|
|
59
|
+
// No port pinned: take a fresh one, so a leftover server from an earlier
|
|
60
|
+
// session cannot collide with it (dequelabs/axe-mcp-server#1013).
|
|
48
61
|
const portRaw = values.port ?? env.AXE_TOKEN_REFRESH_PORT;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
let port;
|
|
63
|
+
const portWasAutoSelected = !portRaw;
|
|
64
|
+
if (portRaw) {
|
|
65
|
+
port = Number(portRaw);
|
|
66
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
|
|
67
|
+
deps.stderr.write(`Invalid port ${portRaw}: must be a TCP port (1-65535)\n`);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
52
70
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
else if (isContainerCommand(childArgv[0])) {
|
|
72
|
+
// A container only reaches a published port, so an ephemeral one would
|
|
73
|
+
// never be reachable. Fail now, not at the first refresh push.
|
|
74
|
+
deps.stderr.write("axe-auth run needs an explicit refresh port when the wrapped command is a container: set AXE_TOKEN_REFRESH_PORT or pass --port, and publish it with `-p 127.0.0.1:<port>:<port>`\n");
|
|
56
75
|
return 2;
|
|
57
76
|
}
|
|
77
|
+
else {
|
|
78
|
+
port = await (0, findFreePort_1.default)();
|
|
79
|
+
}
|
|
58
80
|
// Mirror the server's minimum-length check so a short pinned secret fails
|
|
59
81
|
// here, at the command the user ran, rather than as a downstream server
|
|
60
82
|
// startup crash. An unset secret is generated by `runSession`.
|
|
@@ -69,6 +91,7 @@ async function dispatchRun(rest, deps) {
|
|
|
69
91
|
command: childArgv[0],
|
|
70
92
|
commandArgs: childArgv.slice(1),
|
|
71
93
|
port,
|
|
94
|
+
portWasAutoSelected,
|
|
72
95
|
secret,
|
|
73
96
|
env,
|
|
74
97
|
stderr: deps.stderr,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask the OS for a free loopback port by binding port 0 and releasing it.
|
|
3
|
+
*
|
|
4
|
+
* Sessions used to share one fixed port, so a leftover server held it and the
|
|
5
|
+
* next session lost token refresh (dequelabs/axe-mcp-server#1013). The OS
|
|
6
|
+
* never hands out a port something is still bound to.
|
|
7
|
+
*
|
|
8
|
+
* It is released before the child binds it, so another process could take it
|
|
9
|
+
* in between. That window is not small: it spans minting a token against
|
|
10
|
+
* Keycloak and starting the child. Because the port was not the user's
|
|
11
|
+
* choice, losing it degrades the session to no token refresh rather than
|
|
12
|
+
* failing it (see `portWasAutoSelected` in `runSession`).
|
|
13
|
+
*
|
|
14
|
+
* Nothing proves the listener on that port is our child, so a process that
|
|
15
|
+
* takes it receives whatever is pushed there. Tracked in #1028.
|
|
16
|
+
*/
|
|
17
|
+
export default function findFreePort(host?: string): Promise<number>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = findFreePort;
|
|
4
|
+
const node_net_1 = require("node:net");
|
|
5
|
+
/**
|
|
6
|
+
* Ask the OS for a free loopback port by binding port 0 and releasing it.
|
|
7
|
+
*
|
|
8
|
+
* Sessions used to share one fixed port, so a leftover server held it and the
|
|
9
|
+
* next session lost token refresh (dequelabs/axe-mcp-server#1013). The OS
|
|
10
|
+
* never hands out a port something is still bound to.
|
|
11
|
+
*
|
|
12
|
+
* It is released before the child binds it, so another process could take it
|
|
13
|
+
* in between. That window is not small: it spans minting a token against
|
|
14
|
+
* Keycloak and starting the child. Because the port was not the user's
|
|
15
|
+
* choice, losing it degrades the session to no token refresh rather than
|
|
16
|
+
* failing it (see `portWasAutoSelected` in `runSession`).
|
|
17
|
+
*
|
|
18
|
+
* Nothing proves the listener on that port is our child, so a process that
|
|
19
|
+
* takes it receives whatever is pushed there. Tracked in #1028.
|
|
20
|
+
*/
|
|
21
|
+
async function findFreePort(host = "127.0.0.1") {
|
|
22
|
+
const probe = (0, node_net_1.createServer)();
|
|
23
|
+
try {
|
|
24
|
+
return await new Promise((resolve, reject) => {
|
|
25
|
+
probe.once("error", reject);
|
|
26
|
+
probe.listen(0, host, () => {
|
|
27
|
+
const address = probe.address();
|
|
28
|
+
if (address === null) {
|
|
29
|
+
reject(new Error("could not determine a free port"));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
resolve(address.port);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await new Promise((resolve) => probe.close(() => resolve()));
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/run/pushToken.js
CHANGED
|
@@ -6,6 +6,8 @@ exports.default = pushToken;
|
|
|
6
6
|
exports.REFRESH_SECRET_HEADER = "x-refresh-secret";
|
|
7
7
|
/** Bound the push so a stalled server can't hang the caller. */
|
|
8
8
|
const PUSH_TIMEOUT_MS = 5_000;
|
|
9
|
+
/** How much of a failed push's response body is worth repeating to the user. */
|
|
10
|
+
const MAX_DETAIL_LEN = 200;
|
|
9
11
|
/** POST the fresh token to the server's loopback refresh listener. */
|
|
10
12
|
async function pushToken({ port, secret, accessToken, }) {
|
|
11
13
|
const res = await fetch(`http://127.0.0.1:${port}/token`, {
|
|
@@ -18,7 +20,16 @@ async function pushToken({ port, secret, accessToken, }) {
|
|
|
18
20
|
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
|
|
19
21
|
});
|
|
20
22
|
if (!res.ok) {
|
|
21
|
-
|
|
23
|
+
// Whatever holds this port wrote the body, and it reaches the client's
|
|
24
|
+
// stderr, so take a little of it and strip anything that could forge log
|
|
25
|
+
// lines or redraw the terminal.
|
|
26
|
+
const detail = await res
|
|
27
|
+
.text()
|
|
28
|
+
.then((text) => text
|
|
29
|
+
.slice(0, MAX_DETAIL_LEN)
|
|
30
|
+
.replace(/[^\t\x20-\x7e]/g, " ")
|
|
31
|
+
.trim())
|
|
32
|
+
.catch(() => "");
|
|
22
33
|
throw new Error(`server responded ${res.status}${detail ? `: ${detail}` : ""}`);
|
|
23
34
|
}
|
|
24
35
|
}
|
package/dist/run/runSession.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface RunSessionOptions {
|
|
|
13
13
|
commandArgs: string[];
|
|
14
14
|
/** Loopback port the server's refresh listener is on. */
|
|
15
15
|
port: number;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the port was chosen by us rather than by the user. An auto-picked
|
|
18
|
+
* port can lose a race it is not the user's fault to lose, so a first push
|
|
19
|
+
* that cannot reach it degrades the session instead of failing it.
|
|
20
|
+
*/
|
|
21
|
+
portWasAutoSelected?: boolean;
|
|
16
22
|
/** Shared secret authenticating pushes. Generated if omitted. */
|
|
17
23
|
secret?: string;
|
|
18
24
|
/** Cadence of the refresh-and-push loop, ms. */
|
package/dist/run/runSession.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
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
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -9,7 +42,7 @@ const errors_1 = require("../oauth/errors");
|
|
|
9
42
|
const getValidAccessToken_1 = require("../oauth/getValidAccessToken");
|
|
10
43
|
const tokenStore_1 = require("../oauth/tokenStore");
|
|
11
44
|
const errors_2 = require("../cli/errors");
|
|
12
|
-
const supervise_1 =
|
|
45
|
+
const supervise_1 = __importStar(require("./supervise"));
|
|
13
46
|
const pushToken_1 = __importDefault(require("./pushToken"));
|
|
14
47
|
/**
|
|
15
48
|
* Cadence of the refresh-and-push loop. Must be shorter than the access-token
|
|
@@ -21,6 +54,14 @@ const pushToken_1 = __importDefault(require("./pushToken"));
|
|
|
21
54
|
const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
|
|
22
55
|
/** Consecutive mid-session refresh failures before escalating from a per-tick line to one loud diagnostic. */
|
|
23
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;
|
|
24
65
|
const realScheduler = (intervalMs, tick) => {
|
|
25
66
|
const timer = setInterval(tick, intervalMs);
|
|
26
67
|
timer.unref();
|
|
@@ -42,7 +83,7 @@ const realScheduler = (intervalMs, tick) => {
|
|
|
42
83
|
* consecutive failures.
|
|
43
84
|
*/
|
|
44
85
|
async function runSession(options) {
|
|
45
|
-
const { command, commandArgs, port, 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;
|
|
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;
|
|
46
87
|
const loaded = await tokenStore.load();
|
|
47
88
|
const coordinates = {
|
|
48
89
|
issuerURL: loaded.ok ? loaded.entry.issuerURL : "",
|
|
@@ -68,6 +109,10 @@ async function runSession(options) {
|
|
|
68
109
|
AXE_ACCESS_TOKEN: initialToken,
|
|
69
110
|
AXE_TOKEN_REFRESH_PORT: String(port),
|
|
70
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" } : {}),
|
|
71
116
|
};
|
|
72
117
|
let lastPushed = initialToken;
|
|
73
118
|
let pushedSuccessfully = false;
|
|
@@ -78,6 +123,9 @@ async function runSession(options) {
|
|
|
78
123
|
const fatal = new Promise((_, reject) => {
|
|
79
124
|
rejectFatal = reject;
|
|
80
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();
|
|
81
129
|
// A recoverable refresh failure: log it, and once failures pile up emit one
|
|
82
130
|
// louder line (they will keep failing every tick otherwise). The session
|
|
83
131
|
// stays alive on its last good token.
|
|
@@ -113,13 +161,20 @@ async function runSession(options) {
|
|
|
113
161
|
await push({ port, secret, accessToken: fresh });
|
|
114
162
|
}
|
|
115
163
|
catch (err) {
|
|
116
|
-
if (!pushedSuccessfully) {
|
|
117
|
-
// The first push never reached the
|
|
118
|
-
// misconfigured port/secret. Fail so the
|
|
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.
|
|
119
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;
|
|
120
169
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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");
|
|
123
178
|
}
|
|
124
179
|
return;
|
|
125
180
|
}
|
|
@@ -132,21 +187,33 @@ async function runSession(options) {
|
|
|
132
187
|
}
|
|
133
188
|
})();
|
|
134
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
|
+
});
|
|
135
202
|
try {
|
|
136
|
-
return await Promise.race([
|
|
137
|
-
supervise({
|
|
138
|
-
command,
|
|
139
|
-
args: commandArgs,
|
|
140
|
-
env: childEnv,
|
|
141
|
-
stdin,
|
|
142
|
-
stdout,
|
|
143
|
-
stderr,
|
|
144
|
-
onSignal,
|
|
145
|
-
}),
|
|
146
|
-
fatal,
|
|
147
|
-
]);
|
|
203
|
+
return await Promise.race([superviseResult, fatal]);
|
|
148
204
|
}
|
|
149
205
|
finally {
|
|
150
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
|
+
]);
|
|
151
218
|
}
|
|
152
219
|
}
|
package/dist/run/supervise.d.ts
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
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;
|
|
2
27
|
/** Options for {@link supervise}. */
|
|
3
28
|
export interface SuperviseOptions {
|
|
4
29
|
/** Executable to run (e.g. `docker`, `npx`). */
|
|
@@ -15,6 +40,18 @@ export interface SuperviseOptions {
|
|
|
15
40
|
stderr?: Writable;
|
|
16
41
|
/** Registers a termination-signal handler. Injectable for tests; defaults to `process.on`. */
|
|
17
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;
|
|
18
55
|
}
|
|
19
56
|
/**
|
|
20
57
|
* Spawn `command` and transparently bridge this process's stdio to it: the
|
|
@@ -25,7 +62,11 @@ export interface SuperviseOptions {
|
|
|
25
62
|
* child is killed by a signal (shell convention); rejects only if the child
|
|
26
63
|
* fails to spawn.
|
|
27
64
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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.
|
|
30
71
|
*/
|
|
31
72
|
export default function supervise(options: SuperviseOptions): Promise<number>;
|
package/dist/run/supervise.js
CHANGED
|
@@ -3,11 +3,55 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_TEARDOWN_MS = exports.SERVER_TEARDOWN_BUDGET_MS = exports.TEARDOWN_GRACE_MS = void 0;
|
|
6
7
|
exports.default = supervise;
|
|
7
8
|
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
8
9
|
const node_os_1 = require("node:os");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
9
11
|
/** Signals forwarded from this process to the supervised child. */
|
|
10
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;
|
|
11
55
|
/**
|
|
12
56
|
* Spawn `command` and transparently bridge this process's stdio to it: the
|
|
13
57
|
* parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
|
|
@@ -17,38 +61,165 @@ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
|
17
61
|
* child is killed by a signal (shell convention); rejects only if the child
|
|
18
62
|
* fails to spawn.
|
|
19
63
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
70
|
*/
|
|
23
71
|
function supervise(options) {
|
|
24
72
|
const { command, args, env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, onSignal = (signal, handler) => {
|
|
25
73
|
process.on(signal, handler);
|
|
26
|
-
},
|
|
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;
|
|
27
86
|
return new Promise((resolve, reject) => {
|
|
28
|
-
// Not `detached`: if this process dies without cleanup (e.g. SIGKILL), the
|
|
29
|
-
// closed stdin pipe gives the child EOF, which is how the wrapped server
|
|
30
|
-
// shuts down and avoids being orphaned. Detaching would sever that.
|
|
31
87
|
const child = (0, cross_spawn_1.default)(command, args, {
|
|
32
88
|
env,
|
|
33
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",
|
|
34
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
|
+
};
|
|
35
185
|
child.once("error", reject);
|
|
36
186
|
if (child.stdin) {
|
|
37
187
|
stdin.pipe(child.stdin);
|
|
38
|
-
//
|
|
39
|
-
// closed stdin would otherwise throw EPIPE and crash the supervisor.
|
|
188
|
+
// Writing to a child that already exited throws EPIPE otherwise.
|
|
40
189
|
child.stdin.on("error", () => { });
|
|
41
190
|
}
|
|
42
|
-
//
|
|
43
|
-
|
|
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.
|
|
44
196
|
child.stdout?.pipe(stdout, { end: false });
|
|
45
197
|
child.stderr?.pipe(stderr, { end: false });
|
|
46
198
|
for (const signal of FORWARDED_SIGNALS) {
|
|
47
199
|
onSignal(signal, () => {
|
|
48
|
-
|
|
200
|
+
beginTeardown(signal);
|
|
49
201
|
});
|
|
50
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
|
+
}
|
|
51
220
|
child.once("exit", (code, signal) => {
|
|
221
|
+
exited = true;
|
|
222
|
+
clearTimeout(rungTimer);
|
|
52
223
|
if (child.stdin) {
|
|
53
224
|
stdin.unpipe(child.stdin);
|
|
54
225
|
}
|
|
@@ -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
|
+
}
|
package/docs/architecture.md
CHANGED
|
@@ -134,6 +134,11 @@ sequenceDiagram
|
|
|
134
134
|
|
|
135
135
|
User->>Client: set axe-auth run as the stdio server command
|
|
136
136
|
Client->>CLI: spawn (stdio)
|
|
137
|
+
alt --port or AXE_TOKEN_REFRESH_PORT pinned
|
|
138
|
+
Note over CLI: use it as given (required for a container runtime)
|
|
139
|
+
else nothing pinned
|
|
140
|
+
Note over CLI: take a free loopback port from the OS,<br/>so a leftover server cannot collide
|
|
141
|
+
end
|
|
137
142
|
Note over CLI: mint initial access token (refresh via Keycloak if near expiry)
|
|
138
143
|
opt token near expiry
|
|
139
144
|
CLI->>KC: POST /token (refresh_token)
|
|
@@ -149,15 +154,31 @@ sequenceDiagram
|
|
|
149
154
|
KC-->>CLI: { access_token }
|
|
150
155
|
CLI->>Server: POST /token (x-refresh-secret) — swap in-memory token
|
|
151
156
|
end
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
alt wrapped server exits on its own
|
|
158
|
+
Server-->>CLI: child exits
|
|
159
|
+
CLI-->>Client: exit with the child's code
|
|
160
|
+
else session ends (stdin EOF, forwarded signal, launcher gone, or fatal startup abort)
|
|
161
|
+
CLI->>Server: polite rung — forwarded signal or SIGTERM across the process group, or a closed stdin on Windows
|
|
162
|
+
opt still alive after the grace period
|
|
163
|
+
CLI->>Server: SIGKILL (process group, or taskkill /T /F on Windows)
|
|
164
|
+
end
|
|
165
|
+
Server-->>CLI: child exits
|
|
166
|
+
end
|
|
154
167
|
```
|
|
155
168
|
|
|
156
|
-
1. The developer points their MCP client at `axe-auth run -- <server launch command>` as the stdio server command
|
|
169
|
+
1. The developer points their MCP client at `axe-auth run -- <server launch command>` as the stdio server command. A free loopback refresh port is taken from the OS per session, so a server left behind by an earlier session cannot collide with it; `--port` or `AXE_TOKEN_REFRESH_PORT` pins one instead. A container only reaches a host port it was told to publish with `-p`, so `run` refuses to guess one when it recognises the wrapped command as `docker`, `podman`, or `nerdctl`. Detection is by command name, so another runtime, or one reached through a wrapper script, takes an auto-selected port it cannot reach and degrades to no token refresh; pin a port yourself there.
|
|
157
170
|
2. `run` obtains a currently-valid access token (exactly as `axe-auth token` does, refreshing against Keycloak if needed), generates a shared secret unless one is provided, and launches the wrapped server as a child process with the token, port, and secret injected into its environment.
|
|
158
171
|
3. `run` transparently bridges the client's stdio to the child so the MCP session flows through untouched, and supervises the child for the session's lifetime.
|
|
159
172
|
4. In the background, `run` keeps the access token fresh and pushes each new token to the server's loopback listener at `http://127.0.0.1:<port>/token` (secret in an `x-refresh-secret` header). The refresh token is never sent; only short-lived access tokens.
|
|
160
|
-
5. When the wrapped server exits, `run` exits with the same code.
|
|
173
|
+
5. When the wrapped server exits on its own, `run` exits with the same code.
|
|
174
|
+
6. `run` also ends the session itself, so the server cannot outlive it holding the refresh port: on stdin EOF, on a forwarded `SIGINT`/`SIGTERM`/`SIGHUP`, when the launching process disappears, and on a fatal error. Each puts the child on a teardown ladder: a polite rung the server can shut down on, then a forced one. On macOS and Linux that is the forwarded signal (or `SIGTERM`), then `SIGKILL` across the process group. Windows has no polite signal, so the closed stdin pipe is the polite rung and the forced one is `taskkill /T /F` across the process tree. The wrapped server separately ends the session when the client stops answering `ping`, which catches a client that never closed the pipe.
|
|
175
|
+
|
|
176
|
+
#### Known limitations
|
|
177
|
+
|
|
178
|
+
Two cases are bounded rather than closed.
|
|
179
|
+
|
|
180
|
+
- **A container can outlive an ungraceful teardown.** When the wrapped command is a container runtime, `axe-auth` supervises the client, not the container, so the polite rung is forwarded to the container but `SIGKILL` reaches only the client. A server that does not stop within the grace therefore leaves the container running on the port it published, which the next session cannot reuse. Recover with `docker rm -f <container>`. Pinning a port is already required here, so the collision is not silent. Tracked in [#1027](https://github.com/dequelabs/axe-mcp-server/issues/1027).
|
|
181
|
+
- **A browser can outlive an ungraceful teardown.** On POSIX the final `SIGKILL` reaches the server's process group, but Playwright runs Chromium in its own, so a scan still mid-flight when the grace expires leaves a browser for the user to close by hand. It holds no port, so it does not block the next session. Windows is unaffected: the forced rung there walks the whole process tree.
|
|
161
182
|
|
|
162
183
|
### `axe-auth logout`
|
|
163
184
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deque/axe-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0-next.06cbb604",
|
|
4
4
|
"description": "CLI authentication utility for Deque services",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"repository": {
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"ts-dedent": "^2.2.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@hono/node-server": "^2.0
|
|
40
|
+
"@hono/node-server": "^2.1.0",
|
|
41
41
|
"@types/cross-spawn": "^6.0.6",
|
|
42
|
-
"@types/node": "^24.13.
|
|
43
|
-
"c8": "^
|
|
44
|
-
"hono": "^4.
|
|
45
|
-
"tsx": "^4.
|
|
42
|
+
"@types/node": "^24.13.3",
|
|
43
|
+
"c8": "^12.0.0",
|
|
44
|
+
"hono": "^4.13.5",
|
|
45
|
+
"tsx": "^4.23.12",
|
|
46
46
|
"typescript": "^6.0.3"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|