@deque/axe-auth 1.5.0-rc.bbbeb999 → 1.6.0-next.09b9233d

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 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
- AXE_TOKEN_REFRESH_PORT=9223 npx @deque/axe-auth run -- npx axe-mcp-server
95
+ npx @deque/axe-auth run -- npx axe-mcp-server
96
96
  ```
97
97
 
98
- Set the port with `AXE_TOKEN_REFRESH_PORT` or `--port`. `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.
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 (missing/invalid port, no command\n after `--`, or the refresh channel could not reach the server).\n <n> Otherwise, the wrapped server's own exit code.";
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 (missing/invalid port, no command
35
- after \`--\`, or the refresh channel could not reach the server).
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.`;
@@ -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
- if (!portRaw) {
50
- deps.stderr.write("axe-auth run needs a refresh port: set AXE_TOKEN_REFRESH_PORT or pass --port\n");
51
- return 2;
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
- const port = Number(portRaw);
54
- if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
55
- deps.stderr.write(`Invalid port ${portRaw}: must be a TCP port (1-65535)\n`);
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
+ }
@@ -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
- const detail = await res.text().catch(() => "");
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
  }
@@ -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. */
@@ -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 = __importDefault(require("./supervise"));
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 server — almost always a
118
- // misconfigured port/secret. Fail so the MCP client sees the exit.
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
- else {
122
- noteRecoverableFailure((0, errors_2.describeError)(err));
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
  }
@@ -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
- * This is the core of `axe-auth run`: it lets the MCP client spawn `axe-auth`
29
- * as its stdio server command while axe-auth manages tokens out of band.
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>;
@@ -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
- * This is the core of `axe-auth run`: it lets the MCP client spawn `axe-auth`
21
- * as its stdio server command while axe-auth manages tokens out of band.
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
- }, } = options;
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
- // The child may exit while the parent is still writing; a write to its
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
- // `end: false` so the child closing its stream never tears down the
43
- // parent's shared stdout/stderr.
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
- child.kill(signal);
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
+ }
@@ -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
- Server-->>CLI: child exits
153
- CLI-->>Client: exit with the child's code
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, configuring a loopback refresh port via `--port` or `AXE_TOKEN_REFRESH_PORT`.
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.5.0-rc.bbbeb999",
3
+ "version": "1.6.0-next.09b9233d",
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.11",
40
+ "@hono/node-server": "^2.1.0",
41
41
  "@types/cross-spawn": "^6.0.6",
42
- "@types/node": "^24.13.2",
43
- "c8": "^11.0.0",
44
- "hono": "^4.12.34",
45
- "tsx": "^4.22.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": {