@thehammer/danx-dashboard-mcp 0.1.223 → 0.1.224

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
@@ -9,12 +9,20 @@ Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes
9
9
  | Var | Purpose |
10
10
  |---|---|
11
11
  | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
12
- | `DANXBOT_DASHBOARD_TOKEN_FILE` **or** `DANX_DASHBOARD_CREDENTIAL` | The credential, declared exactly one of two ways (DX-2862, `src/credential.ts`). A FILE holding the bearer (a leading `~` is the user's home) — how an interactive operator session declares one, since its `.mcp.json` is git-tracked and can never carry the token itself. Or the bearer VERBATIM — how a dispatch declares one, substituted from its overlay. A declared file is AUTHORITATIVE and its failure is FATAL: an inherited `DANX_DASHBOARD_CREDENTIAL` is never consulted instead, because on an operator machine that value routinely names a different (local-dev) dashboard, which answers healthily and empty. The same resolver serves the `bridge` subcommand below, so a session's tools and its event stream can never sign in as two different identities |
12
+ | `DANX_DASHBOARD_CREDENTIAL` | A DISPATCH's credential: the bearer verbatim, substituted into the materialized `.mcp.json` from its overlay (DX-2862, `src/credential.ts`). Authoritative when set; it never signs out. An operator session sets nothing: with no credential the server boots SIGNED OUT, every tool except `plan_connect` returns one "not signed in" halt, and `plan_connect` requests access (below). The same resolver serves the `bridge` subcommand, so a session's tools and its event stream can never sign in as two different identities |
13
13
  | `DANX_REPO_NAME` | Repo half of the qualified board id |
14
14
  | `DANXBOT_BOARD_NAME` | Board-slug half of the qualified board id (the dispatch injects `board.slug` here) |
15
15
 
16
16
  BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from board server-side, never passed. At boot the package composes the dispatch's qualified board id `<repo>:<slug>` from `DANX_REPO_NAME` + `DANXBOT_BOARD_NAME` and appends it as `?board=<id>` (and `body.board` on create) to every `/api/issues/*` request. Each tool exposes an optional `board` arg — a FULL qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`) — that overrides it per-call for cross-board reads/writes (unknown board → 404). The dashboard owns board→repo resolution; this package never sends `repo`.
17
17
 
18
+ ## Signing in (DX-4390): request and approve
19
+
20
+ An operator session has no stored credential. Signed out, `plan_connect` (optionally with `title`, the session's own Claude title) posts `POST /api/access-requests` and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`: show the user the code and open the URL; they approve in the dashboard. The server polls `POST /api/access-requests/claim` in the background, so the key is stored when the approval lands; the next `plan_connect` waits up to about 45 seconds for it, then connects, or answers `approval_pending`. One tool call never blocks for minutes. `denied` is reported once; a request that lapsed (the dashboard deletes it within about a minute of expiry, so a claim answers 404) starts a fresh one on the next call.
21
+
22
+ - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; the bridge reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, and the bridge's heartbeat, renew it), and is revocable from the account page.
23
+ - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). **A 404 on create** means the dashboard predates request-and-approve and says so.
24
+ - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
25
+
18
26
  ## Tool surface (DX-3465 — one generic tool, not one per operation)
19
27
 
20
28
  All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API:
@@ -39,7 +47,7 @@ A program runs this, not an agent: the danxbot Claude Code plugin's plan event b
39
47
  CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
40
48
  ```
41
49
 
42
- - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANX_DASHBOARD_CREDENTIAL` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
50
+ - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE (a session key file, or the env var name), and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANX_DASHBOARD_CREDENTIAL` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
43
51
  - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED. The dashboard briefly enforced the identical requirement server-side (DX-2863: refusing a mint or stream admission with `403 issuer_cannot_read_plan_boards`, and ending a live stream `{"reason":"scope_narrowed","boards":[…]}`), but DX-3468 removed that mechanism too — DX-3409 made every credential team-bound, so a plan session's issuer and every board its connected plan covers are always in the same team, and the check could never fire any more. `bridge` no longer independently verifies board reach at all, client- or server-side. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
44
52
  - **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
45
53
  - **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
@@ -0,0 +1,90 @@
1
+ import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
2
+ function baseOf(dashboardUrl) {
3
+ return dashboardUrl.replace(/\/+$/, "");
4
+ }
5
+ async function postPublic(fetchImpl, url, body) {
6
+ let res;
7
+ try {
8
+ res = await fetchImpl(url, {
9
+ method: "POST",
10
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
11
+ body: JSON.stringify(body),
12
+ });
13
+ }
14
+ catch (err) {
15
+ throw new Error(`[danx-dashboard-mcp] network failure on POST ${url}: ${err instanceof Error ? err.message : String(err)}`);
16
+ }
17
+ assertVersionFloorSatisfied(res.headers.get(MCP_MIN_VERSION_HEADER));
18
+ const text = await res.text();
19
+ if (res.status >= 500) {
20
+ throw new Error(`[danx-dashboard-mcp] server error ${res.status} on POST ${url}: ${text.slice(0, 500)}`);
21
+ }
22
+ let json = null;
23
+ if (text !== "") {
24
+ try {
25
+ json = JSON.parse(text);
26
+ }
27
+ catch {
28
+ throw new Error(`[danx-dashboard-mcp] non-JSON response (status ${res.status}) from POST ${url}: ${text.slice(0, 200)}`);
29
+ }
30
+ }
31
+ return { status: res.status, headers: res.headers, json };
32
+ }
33
+ /** When a 429 carries no usable `Retry-After`, wait this long before the next try. */
34
+ const DEFAULT_RETRY_AFTER_SECONDS = 1;
35
+ function retryAfterSeconds(headers) {
36
+ const seconds = Number(headers.get("retry-after"));
37
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_RETRY_AFTER_SECONDS;
38
+ }
39
+ function isCreated(value) {
40
+ if (typeof value !== "object" || value === null)
41
+ return false;
42
+ const v = value;
43
+ return (typeof v.publicId === "string" &&
44
+ typeof v.secret === "string" &&
45
+ typeof v.confirmCode === "string" &&
46
+ typeof v.approvalUrl === "string" &&
47
+ typeof v.expiresAt === "string" &&
48
+ Number.isFinite(Date.parse(v.expiresAt)) &&
49
+ typeof v.pollMs === "number");
50
+ }
51
+ export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
52
+ const url = `${baseOf(dashboardUrl)}/api/access-requests`;
53
+ const { status, headers, json } = await postPublic(fetchImpl, url, input);
54
+ if (status === 201) {
55
+ if (!isCreated(json)) {
56
+ throw new Error(`[danx-dashboard-mcp] POST ${url} answered 201 without the access request fields: ${JSON.stringify(json)}`);
57
+ }
58
+ return { kind: "created", request: json };
59
+ }
60
+ if (status === 404)
61
+ return { kind: "dashboard_outdated" };
62
+ if (status === 429)
63
+ return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
64
+ return { kind: "refused", status, body: json };
65
+ }
66
+ export async function claimAccessRequest(fetchImpl, dashboardUrl, secret) {
67
+ const url = `${baseOf(dashboardUrl)}/api/access-requests/claim`;
68
+ const { status, headers, json } = await postPublic(fetchImpl, url, { secret });
69
+ if (status === 404)
70
+ return { kind: "not_found" };
71
+ if (status === 429)
72
+ return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
73
+ if (status !== 200)
74
+ return { kind: "refused", status, body: json };
75
+ const body = json;
76
+ switch (body?.status) {
77
+ case "pending":
78
+ case "denied":
79
+ case "expired":
80
+ case "claimed":
81
+ return { kind: body.status };
82
+ case "approved":
83
+ if (typeof body.apiKey !== "string" || body.apiKey === "") {
84
+ throw new Error(`[danx-dashboard-mcp] POST ${url} answered approved without an apiKey`);
85
+ }
86
+ return { kind: "approved", apiKey: body.apiKey };
87
+ default:
88
+ throw new Error(`[danx-dashboard-mcp] POST ${url} answered an unknown status: ${JSON.stringify(json)}`);
89
+ }
90
+ }
package/dist/bridge.js CHANGED
@@ -216,11 +216,11 @@ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${B
216
216
  export const STOP_FIXES = {
217
217
  no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
218
218
  "(a server older than the one that introduced the record writes none)",
219
- credential_unavailable: "fix the credential this session's danx-dashboard MCP server declares, then call plan_connect again",
219
+ credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
220
220
  credential_mismatch: "restart this Claude Code session so its MCP server and its bridge pick up the same credential, then call plan_connect again",
221
221
  not_connected: "call plan_connect to connect this session to a plan",
222
222
  plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
223
- unauthorized: "give this session's danx-dashboard MCP server a dashboard credential that is accepted, then call plan_connect again",
223
+ unauthorized: "the dashboard no longer accepts this session's key (revoked, or lapsed after a day unused): call plan_connect again to request access",
224
224
  mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
225
225
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
226
226
  // A newer ticket exists (superseded) or another connection is already using
@@ -300,13 +300,13 @@ function resolveInstanceId(env) {
300
300
  }
301
301
  /**
302
302
  * DX-3028 — the on-disk paths this bridge's authentication depends on, for
303
- * `BridgeOptions.watchedPaths` (see its docblock). A `file`-sourced
303
+ * `BridgeOptions.watchedPaths` (see its docblock). A `session-key`
304
304
  * credential adds its own path; an `env`-sourced one has no file
305
305
  * (`credential.ts` ~47-48, ~90-95).
306
306
  */
307
307
  function watchedPathsFor(record, home) {
308
308
  const paths = [sessionConnectionPath(record.sessionId, home)];
309
- if (record.source.kind === "file")
309
+ if (record.source.kind === "session-key")
310
310
  paths.push(record.source.path);
311
311
  return paths;
312
312
  }
@@ -1,50 +1,34 @@
1
1
  /**
2
- * DX-2862 — the ONE resolver for this package's dashboard credential.
2
+ * DX-2862 / DX-4390 - the ONE resolver for this package's dashboard credential.
3
3
  *
4
- * WHY IT IS ONE FUNCTION AND NOT TWO. Two processes speak to the dashboard for
5
- * the same Claude Code session: the MCP SERVER (the session's card + plan tools)
6
- * and `danx-dashboard-mcp bridge` (the session's event stream, run by the
7
- * danxbot plugin's plan event bridge). Before this module they resolved their
8
- * credential independently — the server through danxbot's operator launcher,
9
- * which read a token file, and the bridge from whatever credential variable
10
- * happened to be in the session's environment. On a machine where those two
11
- * differ (DX-2862: `ffe9f8a882f0…` in the environment, `6f9bc6e94b83…` in
12
- * `~/.config/danxbot/dashboard-token-gpt`) the session's tools worked, the
13
- * bridge's stream was admitted, and every operator comment was dropped with no
14
- * error anywhere. So resolution lives HERE, both callers go through it, and the
15
- * bridge additionally PROVES it holds the server's credential by comparing
16
- * fingerprints (`session-connection.ts`).
4
+ * WHY IT IS ONE FUNCTION AND NOT TWO. Two processes speak to the dashboard for the same Claude
5
+ * Code session: the MCP SERVER (the session's card + plan tools) and `danx-dashboard-mcp bridge`
6
+ * (the session's event stream, run by the danxbot plugin's plan event bridge). When they resolved
7
+ * their credential independently they could differ (DX-2862): the session's tools worked, the
8
+ * bridge's stream was admitted, and every operator comment was dropped with no error anywhere.
9
+ * So resolution lives HERE, both callers go through it, and the bridge additionally PROVES it
10
+ * holds the server's credential by comparing fingerprints (`session-connection.ts`).
17
11
  *
18
- * THE DECLARATION. A server's env declares where its credential comes from:
12
+ * A credential comes from exactly one of two places:
19
13
  *
20
- * - `DANXBOT_DASHBOARD_TOKEN_FILE` — a path (a leading `~` is this user's
21
- * home). The file's trimmed contents are the credential. This is how an
22
- * interactive operator session declares one, because its `.mcp.json` is
23
- * git-tracked and can never carry the bearer itself.
24
- * - `DANX_DASHBOARD_CREDENTIAL` — the credential verbatim. This is how a
25
- * DISPATCH declares one: the worker substitutes it into the materialized
26
- * `.mcp.json` from the dispatch overlay.
14
+ * - `DANX_DASHBOARD_CREDENTIAL` - the bearer verbatim. A DISPATCH declares it: the worker
15
+ * substitutes it into the materialized `.mcp.json` from the dispatch overlay. Authoritative
16
+ * when set, and it never signs out.
17
+ * - a SESSION KEY (`session-key.ts`) - the key a user approved for this one session through
18
+ * `plan_connect`. An operator session has no other credential: with neither of these it boots
19
+ * signed out, and `plan_connect` asks for access.
27
20
  *
28
- * A DECLARED FILE IS AUTHORITATIVE, AND ITS FAILURE IS FATAL. When the file
29
- * variable is set, `DANX_DASHBOARD_CREDENTIAL` is never consulted — not even when
30
- * the file is missing or empty. That is the DX-2483 rule, moved here from the
31
- * launcher it used to live in: an operator machine routinely carries an
32
- * inherited env credential naming the LOCAL dev dashboard, which
33
- * answers healthily and empty for a board it does not carry, so preferring it
34
- * after a failed file read would turn a loud, fixable error into exactly the
35
- * silent wrong-dashboard answer this whole module exists to prevent.
21
+ * There is no token-file source (DX-4390): a long-lived bearer placed on disk by hand is exactly
22
+ * what request-and-approve replaces.
36
23
  */
37
24
  import { createHash } from "node:crypto";
38
- import { readFileSync } from "node:fs";
39
25
  import { homedir } from "node:os";
40
- import { isAbsolute, join, resolve } from "node:path";
41
- export const CREDENTIAL_FILE_ENV = "DANXBOT_DASHBOARD_TOKEN_FILE";
26
+ import { SessionKeyAbsentError, readSessionKeyFile, sessionKeyPath, } from "./session-key.js";
42
27
  export const CREDENTIAL_TOKEN_ENV = "DANX_DASHBOARD_CREDENTIAL";
43
28
  /**
44
- * A credential that cannot be resolved. Carries the remedy beside the reason,
45
- * because both of this module's callers report failures to a human who is not
46
- * reading a log: the server exits with it on stderr, and the bridge posts it
47
- * into the session's inbox.
29
+ * A credential that cannot be resolved. Carries the remedy beside the reason, because both of this
30
+ * module's callers report failures to a human who is not reading a log: the server prints it to
31
+ * stderr, and the bridge posts it into the session's inbox.
48
32
  */
49
33
  export class CredentialError extends Error {
50
34
  fix;
@@ -54,72 +38,68 @@ export class CredentialError extends Error {
54
38
  this.fix = fix;
55
39
  }
56
40
  }
57
- const DECLARE_ONE = `declare the credential in this MCP server's env: ${CREDENTIAL_FILE_ENV}=<path to a file holding the ` +
58
- `dashboard bearer> (an operator session), or ${CREDENTIAL_TOKEN_ENV}=<the bearer> (a dispatch)`;
59
- /** `~` / `~/x` / `~\x` mean this user's home; every other path is returned as given. */
60
- export function expandHome(path, home = homedir()) {
61
- if (path === "~")
62
- return home;
63
- if (path.startsWith("~/") || path.startsWith("~\\"))
64
- return join(home, path.slice(2));
65
- return path;
66
- }
67
41
  function readEnv(env, name) {
68
42
  const value = env[name];
69
43
  return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
70
44
  }
71
- /**
72
- * WHICH source this environment declares. Throws `CredentialError` when it
73
- * declares none — never a guess, and never an anonymous client.
74
- */
75
- export function declaredCredentialSource(env, home = homedir()) {
76
- const file = readEnv(env, CREDENTIAL_FILE_ENV);
77
- if (file !== null) {
78
- const expanded = expandHome(file, home);
79
- return { kind: "file", path: isAbsolute(expanded) ? expanded : resolve(expanded) };
80
- }
81
- if (readEnv(env, CREDENTIAL_TOKEN_ENV) !== null)
82
- return { kind: "env", name: CREDENTIAL_TOKEN_ENV };
83
- throw new CredentialError(`no dashboard credential is declared (neither ${CREDENTIAL_FILE_ENV} nor ${CREDENTIAL_TOKEN_ENV} is set)`, DECLARE_ONE);
84
- }
85
45
  /** One line naming a source, for a message a human reads. Holds no secret. */
86
46
  export function describeCredentialSource(source) {
87
- return source.kind === "file" ? `the token file ${source.path}` : `the environment variable ${source.name}`;
47
+ return source.kind === "session-key"
48
+ ? `the session key file ${source.path}`
49
+ : `the environment variable ${source.name}`;
88
50
  }
89
51
  /** The credential a source yields. Every failure is fatal and named. */
90
52
  export function readCredential(source, env) {
91
53
  if (source.kind === "env") {
92
54
  const token = readEnv(env, source.name);
93
55
  if (token === null) {
94
- throw new CredentialError(`${describeCredentialSource(source)} is unset or empty in this process`, `set ${source.name} for this process, or declare ${CREDENTIAL_FILE_ENV} instead`);
56
+ throw new CredentialError(`${describeCredentialSource(source)} is unset or empty in this process`, `set ${source.name} for this process`);
95
57
  }
96
58
  return token;
97
59
  }
98
- let contents;
99
60
  try {
100
- contents = readFileSync(source.path, "utf-8");
61
+ return readSessionKeyFile(source.path);
101
62
  }
102
63
  catch (err) {
103
- throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, `create that file holding this dashboard's bearer token, or point ${CREDENTIAL_FILE_ENV} at one that exists`);
104
- }
105
- const token = contents.trim();
106
- if (token === "") {
107
- throw new CredentialError(`${describeCredentialSource(source)} is empty`, `write this dashboard's bearer token into that file`);
64
+ throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, "call plan_connect to request access again; the key file is written when the request is approved");
108
65
  }
109
- return token;
110
66
  }
111
67
  /**
112
- * A credential's IDENTITY, for comparing two processes' credentials without
113
- * either of them handling the other's. The first 12 hex of SHA-256 — 48 bits,
114
- * enough that two distinct 48-character tokens colliding is not a thing that
115
- * happens, and nothing an attacker can invert.
68
+ * A credential's IDENTITY, for comparing two processes' credentials without either of them
69
+ * handling the other's. The first 12 hex of SHA-256 - 48 bits, enough that two distinct tokens
70
+ * colliding is not a thing that happens, and nothing an attacker can invert.
116
71
  */
117
72
  export function credentialFingerprint(token) {
118
73
  return createHash("sha256").update(token).digest("hex").slice(0, 12);
119
74
  }
120
- /** Declare, read, fingerprint — what the MCP server does at boot. */
121
- export function resolveDeclaredCredential(env, home = homedir()) {
122
- const source = declaredCredentialSource(env, home);
123
- const token = readCredential(source, env);
124
- return { source, token, fingerprint: credentialFingerprint(token) };
75
+ /**
76
+ * This session's stored key, or `null` (never signed in here). A key file that is there but
77
+ * unusable throws `SessionKeyUnusableError`: the caller decides what to do with the file, nothing
78
+ * is guessed here. Never reads the environment: an operator tool that must authenticate as the
79
+ * session (`make finish-worktree`) uses this alone, so an inherited dispatch credential cannot win.
80
+ */
81
+ export function resolveSessionKeyCredential(where) {
82
+ if (where.sessionId === undefined)
83
+ return null;
84
+ const path = sessionKeyPath(where.dashboardUrl, where.sessionId, where.home ?? homedir());
85
+ try {
86
+ const key = readSessionKeyFile(path);
87
+ return { source: { kind: "session-key", path }, token: key, fingerprint: credentialFingerprint(key) };
88
+ }
89
+ catch (err) {
90
+ if (err instanceof SessionKeyAbsentError)
91
+ return null;
92
+ throw err;
93
+ }
94
+ }
95
+ /**
96
+ * What this server boots with: the dispatch credential when the env declares one, else this
97
+ * session's stored key, else `null` (signed out).
98
+ */
99
+ export function resolveBootCredential(env, where) {
100
+ const token = readEnv(env, CREDENTIAL_TOKEN_ENV);
101
+ if (token !== null) {
102
+ return { source: { kind: "env", name: CREDENTIAL_TOKEN_ENV }, token, fingerprint: credentialFingerprint(token) };
103
+ }
104
+ return resolveSessionKeyCredential(where);
125
105
  }
@@ -10,7 +10,18 @@ export class DashboardHttpClient {
10
10
  this.config = config;
11
11
  this.fetchImpl = fetchImpl;
12
12
  }
13
+ /**
14
+ * DX-4390 — the bearer this client stamps. A session signs in (or out) while the server runs, so
15
+ * the key is not fixed at construction; "" means signed out.
16
+ */
17
+ setToken(token) {
18
+ this.config.token = token;
19
+ }
13
20
  async request(args) {
21
+ if (this.config.token === "") {
22
+ // DX-4390 — a caller reaches here only past the sign-in halt; a bearer-less call is a bug.
23
+ throw new Error("[danx-dashboard-mcp] request attempted while signed out");
24
+ }
14
25
  const url = this.buildUrl(args.method, args.path, args.query, args.board, args.basePath);
15
26
  const headers = {
16
27
  Authorization: `Bearer ${this.config.token}`,
package/dist/index.js CHANGED
@@ -49,15 +49,13 @@
49
49
  *
50
50
  * Env at boot (validated fail-loud — missing → process.exit(1)):
51
51
  * DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
52
- * the CREDENTIAL, declared exactly one of two ways and resolved by the ONE
53
- * resolver this package's event bridge also uses (`credential.ts`, DX-2862):
54
- * DANXBOT_DASHBOARD_TOKEN_FILE a file holding the bearer (an operator
55
- * session, whose `.mcp.json` is git-tracked and so
56
- * can never carry one); authoritative when set, and
57
- * its failure is fatal — an inherited
58
- * DANX_DASHBOARD_CREDENTIAL is never consulted instead
52
+ * the CREDENTIAL (DX-4390), resolved by the ONE resolver this package's event bridge
53
+ * also uses (`credential.ts`, DX-2862), and never a reason to exit:
59
54
  * DANX_DASHBOARD_CREDENTIAL the bearer verbatim (a dispatch, substituted into
60
55
  * the materialized `.mcp.json` from its overlay)
56
+ * otherwise this session's approved key (`session-key.ts`), else none: the
57
+ * server boots SIGNED OUT, every tool but `plan_connect`
58
+ * halts, and `plan_connect` requests access (`session-access.ts`)
61
59
  * DANX_REPO_NAME repo half of the qualified board id
62
60
  * DANXBOT_BOARD_NAME board-slug half of the qualified board id
63
61
  * (the dispatch injects `board.slug` here);
@@ -78,7 +76,10 @@ import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./backgrou
78
76
  import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
79
77
  import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
80
78
  import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
81
- import { resolveDeclaredCredential } from "./credential.js";
79
+ import { resolveBootCredential } from "./credential.js";
80
+ import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
81
+ import { SessionAccess } from "./session-access.js";
82
+ import { readOwnVersion } from "./version-floor.js";
82
83
  import { projectKeyOf, recordSessionConnectionAfterConnect, removeSessionConnectionAfterLeave } from "./session-connection.js";
83
84
  import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
84
85
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -121,12 +122,12 @@ let client;
121
122
  // DX-3997 — late-bound with `client`; see `listener-reminder.ts`.
122
123
  let listenerReminder;
123
124
  /**
124
- * DX-2862 — what this server would tell the session's plan event bridge about
125
- * how to reach the dashboard AS THIS SERVER: the URL, where the credential came
126
- * from, and the credential's fingerprint. Never the credential itself. Written
127
- * to the session's connection record on a successful `plan_connect`.
125
+ * DX-4390 — this server's credential: a dispatch's env bearer, this session's approved key, or
126
+ * none (signed out). It also tells the session's plan event bridge how to reach the dashboard AS
127
+ * THIS SERVER (`access.connection()`: the URL, where the credential is, its fingerprint; never the
128
+ * credential itself), written to the session's connection record on a successful `plan_connect`.
128
129
  */
129
- let connection;
130
+ let access;
130
131
  /**
131
132
  * Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
132
133
  * halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
@@ -135,25 +136,32 @@ let connection;
135
136
  * Both halves are fail-loud required (Core Principle 1 — no fallback).
136
137
  */
137
138
  function boot() {
138
- const credential = resolveCredentialOrDie();
139
+ const dashboardUrl = readEnvOrDie("DANXBOT_DASHBOARD_URL");
140
+ const repo = readEnvOrDie("DANX_REPO_NAME");
141
+ const session = readSessionConfig();
139
142
  config = {
140
- baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
141
- token: credential.token,
142
- board: `${readEnvOrDie("DANX_REPO_NAME")}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
143
+ baseUrl: dashboardUrl,
144
+ // DX-4390 — set by `SessionAccess` below; "" while signed out.
145
+ token: "",
146
+ board: `${repo}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
143
147
  // DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
144
148
  // outbound call so the agent's card writes chain under the launch; absent
145
149
  // (untraced dispatch) → omitted, and the dashboard mints a fresh root.
146
150
  traceparent: readEnvOptional("DANXBOT_TRACEPARENT"),
147
151
  // DX-2683 — the working session, when there is one. See below.
148
- session: readSessionConfig(),
149
- };
150
- connection = {
151
- dashboardUrl: config.baseUrl.replace(/\/+$/, ""),
152
- source: credential.source,
153
- fingerprint: credential.fingerprint,
152
+ session,
154
153
  };
155
154
  client = new DashboardHttpClient(config);
156
155
  const dashboardClient = client;
156
+ access = new SessionAccess({
157
+ dashboardUrl,
158
+ sessionId: session?.id,
159
+ repo,
160
+ clientVersion: readOwnVersion(),
161
+ setToken: (token) => dashboardClient.setToken(token),
162
+ initial: resolveInitialCredential(dashboardUrl, session?.id),
163
+ log: (line) => console.error(line),
164
+ });
157
165
  listenerReminder = createListenerReminder({
158
166
  sessionId: config.session?.id,
159
167
  fetchHealth: () => dashboardClient.request({ method: "GET", basePath: "", path: "/api/plans", query: { limit: 1 } }),
@@ -171,19 +179,22 @@ function boot() {
171
179
  applyLifecycleVisibility(lifecycleTools, readLifecycleUrlsFromEnv());
172
180
  }
173
181
  /**
174
- * DX-2862 — the credential, through the ONE resolver the `bridge` subcommand
175
- * also uses, so a session's tools and its event stream can never sign in as two
176
- * different identities. A credential that cannot be resolved exits non-zero
177
- * naming the remedy, never a degraded or anonymous server.
182
+ * DX-2862 / DX-4390 — the credential this server boots with, through the ONE resolver the
183
+ * `bridge` subcommand also uses, so a session's tools and its event stream can never sign in as
184
+ * two different identities. None is not an error: the server boots signed out. A stored key that
185
+ * is there but unusable is reported loudly and deleted - it is a cache of a revocable key, and the
186
+ * session asks for a new one.
178
187
  */
179
- function resolveCredentialOrDie() {
188
+ function resolveInitialCredential(dashboardUrl, sessionId) {
180
189
  try {
181
- return resolveDeclaredCredential(process.env);
190
+ return resolveBootCredential(process.env, { dashboardUrl, sessionId });
182
191
  }
183
192
  catch (err) {
184
- const credentialError = err;
185
- console.error(`[danx-dashboard-mcp] ${credentialError.message}\n Fix: ${credentialError.fix}`);
186
- process.exit(1);
193
+ if (!(err instanceof SessionKeyUnusableError) || sessionId === undefined)
194
+ throw err;
195
+ console.error(`[danx-dashboard-mcp] ${err.message}; deleting it, this session must request access again`);
196
+ deleteSessionKey(err.path);
197
+ return null;
187
198
  }
188
199
  }
189
200
  /**
@@ -261,6 +272,21 @@ export const server = new McpServer({
261
272
  function haltResult(text) {
262
273
  return { content: [{ type: "text", text }], isError: true };
263
274
  }
275
+ /**
276
+ * DX-4390 — `plan_connect`'s answer while access is not in place: the request (or why there is none),
277
+ * an error when it names a failure. `note` says why a new request was needed.
278
+ */
279
+ function accessReply(ready, note) {
280
+ if (ready.signedIn)
281
+ return jsonResult({ state: "signed_in", ...(note ? { note } : {}) });
282
+ const answer = note ? { ...ready.answer, note } : ready.answer;
283
+ return ready.isError ? haltResult(JSON.stringify(answer, null, 2)) : jsonResult(answer);
284
+ }
285
+ /** DX-4390 — a tool's dashboard call behind the sign-in halt (and the 401 sign-out). */
286
+ async function guardedJson(run) {
287
+ const guarded = await access.guard(run);
288
+ return "halt" in guarded ? haltResult(guarded.halt) : jsonResult(guarded.result);
289
+ }
264
290
  function jsonResult(value) {
265
291
  return {
266
292
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
@@ -371,7 +397,10 @@ async (args) => {
371
397
  const refused = termsAcceptanceCallHalt(args, config.baseUrl);
372
398
  if (refused)
373
399
  return haltResult(refused);
374
- const result = await danxbotApi(client, args);
400
+ const guarded = await access.guard(() => danxbotApi(client, args));
401
+ if ("halt" in guarded)
402
+ return haltResult(guarded.halt);
403
+ const result = guarded.result;
375
404
  const halt = termsHaltFor(result);
376
405
  if (halt)
377
406
  return haltResult(halt);
@@ -382,7 +411,7 @@ strictTool("danxbot_api_list",
382
411
  // DX-3528 — the list is filtered by the same permission check every request takes.
383
412
  "Lists the dashboard API routes your credential may call: `[{method, path, summary, permission}]`, each " +
384
413
  "`path` a route template (`/api/issues/:id/transition`), the form `danxbot_api_spec` takes. A route not " +
385
- "listed is not callable by you.", {}, async () => jsonResult(await danxbotApiList(client)));
414
+ "listed is not callable by you.", {}, async () => guardedJson(() => danxbotApiList(client)));
386
415
  // ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
387
416
  strictTool("danxbot_api_spec",
388
417
  // DX-3527 — the contract is generated from the route table.
@@ -391,7 +420,7 @@ strictTool("danxbot_api_spec",
391
420
  '(e.g. `"POST /api/issues/:id/transition"`), never a real path with ids. An endpoint the dashboard does ' +
392
421
  "not serve, or your credential may not call, is refused and named.", {
393
422
  endpoints: z.array(z.string().min(1)).min(1),
394
- }, async (args) => jsonResult(await danxbotApiSpec(client, args)));
423
+ }, async (args) => guardedJson(() => danxbotApiSpec(client, args)));
395
424
  // ---------------- attach_file (DX-3465 A4, checklist item 36140) ----------------
396
425
  //
397
426
  // The "25 MB" ceiling quoted below tracks the backend route's
@@ -404,8 +433,9 @@ strictTool("danxbot_api_spec",
404
433
  // might hold locally, e.g. the Slack recovery agent's CSV path).
405
434
  strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and uploads the bytes, so they never pass through your " +
406
435
  "context. The attachment mirrors to the card's linked Trello card and Slack thread. 25 MB limit (413). " +
407
- "Returns `{issue, attachment_id, url, thumbnail_url, is_primary}`: `url` is permanent, embed it in any " +
408
- "markdown field as `![description](url)` and it renders inline. Use it when a screenshot or file judges something faster than prose, or instead of pasting a " +
436
+ "Returns `{issue, attachment_id, s3_key}`: the entry of `issue.attachments` with that `attachment_id` " +
437
+ "carries a permanent `url`: embed it in any markdown field as `![description](url)` and it renders " +
438
+ "inline. Use it when a screenshot or file judges something faster than prose, or instead of pasting a " +
409
439
  "large CSV or log into a card field.", {
410
440
  card_id: z.string().min(1).describe("The card id to attach the file to."),
411
441
  path: z
@@ -415,7 +445,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
415
445
  filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
416
446
  content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
417
447
  ...boardField,
418
- }, async (args) => jsonResult(await attachFile(client, args)));
448
+ }, async (args) => guardedJson(() => attachFile(client, args)));
419
449
  // ---------------- plans (DX-2683) ----------------
420
450
  // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
421
451
  // takes a plan id in its path; the plan records, plan cards and
@@ -429,14 +459,22 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
429
459
  strictTool("plan_connect",
430
460
  // DX-4318 — states only what to do with the reply. What it used to add arrives where it is used: a rejected
431
461
  // action's rule in the event line itself (`listen.ts`), the mantra in the reply's own `reminders[]` text.
432
- "Connect THIS session to a plan; one call is enough to start. The reply carries `missedEvents` (operator " +
462
+ // DX-4390 — signed out, this is also how the session asks for access.
463
+ "Connect THIS session to a plan; one call is enough to start. Signed out, it requests access instead: " +
464
+ "the reply is `state: approval_required` or `approval_pending` with an `approvalUrl` and `confirmCode` " +
465
+ "(show the code, open the URL, then call again). Signed in, the reply carries `missedEvents` (operator " +
433
466
  "events on the plan that no session had been told about, oldest first: read them first), " +
434
467
  "`browserInstruction` (do it now), `briefing` (the plan's goals, rules, caveats, architecture section " +
435
468
  "titles and a first page of open cards) and `listenerHealth` (when its `state` is not `healthy`, do its " +
436
469
  "`nextStep`). A session is on at most one plan: connecting elsewhere moves it, and `movedFrom` names the " +
437
470
  "plan it left. Afterwards a `danxbot_api` call to a `/api/plans/mine/...` route acts on this plan and " +
438
471
  "takes no plan id.", {
439
- plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
472
+ plan_id: z
473
+ .number()
474
+ .int()
475
+ .positive()
476
+ .optional()
477
+ .describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`). Omit only to sign in."),
440
478
  title: z
441
479
  .string()
442
480
  .min(1)
@@ -449,7 +487,25 @@ strictTool("plan_connect",
449
487
  // session may have configured, the one that connected the plan is the one
450
488
  // whose credential the stream must be minted with.
451
489
  async (args) => {
452
- const result = await planConnect(client, args);
490
+ // DX-4390 — a leave names the plan it leaves, and needs a key like any other call.
491
+ if (args.disconnect === true && args.plan_id === undefined) {
492
+ return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
493
+ }
494
+ // DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
495
+ const ready = await access.ensureAccess(args.title);
496
+ if (!ready.signedIn)
497
+ return accessReply(ready);
498
+ if (args.plan_id === undefined) {
499
+ return jsonResult({
500
+ state: "signed_in",
501
+ instruction: "This session is signed in. Call `plan_connect` with `plan_id` to connect it to a plan.",
502
+ });
503
+ }
504
+ const result = await planConnect(client, { ...args, plan_id: args.plan_id });
505
+ // DX-4390 — the dashboard no longer accepts the stored key: drop it and ask again in the same call.
506
+ if (result.status === 401 && access.dropKey()) {
507
+ return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
508
+ }
453
509
  // DX-3997 — a (re)connect restarts the bridge; the next `danxbot_api` must re-read health.
454
510
  listenerReminder.invalidate();
455
511
  // DX-2985 — a leave drops the local connection record (the bridge exits on `not_connected`; a later connect writes a fresh one).
@@ -457,6 +513,9 @@ async (args) => {
457
513
  removeSessionConnectionAfterLeave(result, config.session?.id);
458
514
  return jsonResult(result);
459
515
  }
516
+ const connection = access.connection();
517
+ if (connection === null)
518
+ throw new Error("plan_connect: no credential after a successful connect");
460
519
  return jsonResult({
461
520
  ...result,
462
521
  ...recordSessionConnectionAfterConnect(result, {
@@ -0,0 +1,265 @@
1
+ /**
2
+ * DX-4390 (PLAN-17 section 96) - how this server gets, holds and loses its dashboard credential.
3
+ *
4
+ * Three states, none of them a process exit:
5
+ *
6
+ * - `env` a dispatch declared `DANX_DASHBOARD_CREDENTIAL`; this never signs out.
7
+ * - `session-key` a user approved this session's access request; the key is stored per session
8
+ * (`session-key.ts`) and a 401 on it deletes the file (`dropKey`).
9
+ * - signed out no credential. Every tool but `plan_connect` halts; `plan_connect` asks.
10
+ *
11
+ * ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `plan_connect` posts the request and
12
+ * answers at once with the approval URL and confirm code. From then on THIS object polls claim in
13
+ * the background, so the key is stored the moment the user approves, and the next `plan_connect`
14
+ * waits at most `awaitMs` (about 45s) for it before answering `approval_pending`. The secret the
15
+ * poll needs lives only in this process's memory; a restarted session asks again.
16
+ */
17
+ import { existsSync } from "node:fs";
18
+ import { hostname } from "node:os";
19
+ import { claimAccessRequest, createAccessRequest } from "./access-request.js";
20
+ import { credentialFingerprint } from "./credential.js";
21
+ import { deleteSessionKey, writeSessionKey } from "./session-key.js";
22
+ import { McpOutdatedError } from "./version-floor.js";
23
+ /** Longest a `plan_connect` call waits for an approval already asked for. */
24
+ export const AWAIT_APPROVAL_MS = 45_000;
25
+ /** The server reaps a lapsed request about a minute after expiry; polling past that finds nothing. */
26
+ const POLL_GRACE_MS = 90_000;
27
+ /** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
28
+ const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
29
+ /** The request's title when the agent gave none: the session id's first characters. */
30
+ const TITLE_FALLBACK_LENGTH = 8;
31
+ export const SIGN_IN_HALT = "Not signed in to the danxbot dashboard. Call `plan_connect` (with `title`: your session's own " +
32
+ "title) to request access; the user approves it in their browser, then this tool works.";
33
+ export const REVOKED_HALT = "The dashboard no longer accepts this session's key (it was revoked, or it lapsed after a day " +
34
+ "unused), so this session is signed out. " +
35
+ SIGN_IN_HALT;
36
+ const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
37
+ "matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval.";
38
+ const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve. Remind them of the confirm code and the URL, then call " +
39
+ "`plan_connect` again.";
40
+ const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
41
+ const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
42
+ "it must be updated before this session can sign in";
43
+ const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
44
+ const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
45
+ const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
46
+ "start a new request";
47
+ const EXPIRED_NOTE = "The previous request expired before it was approved.";
48
+ const defaultSleep = (ms) => new Promise((resolve) => {
49
+ setTimeout(resolve, ms).unref();
50
+ });
51
+ const notReady = (answer, isError) => ({
52
+ signedIn: false,
53
+ answer,
54
+ isError,
55
+ });
56
+ export class SessionAccess {
57
+ deps;
58
+ credential;
59
+ pending = null;
60
+ // DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
61
+ ensureQueue = Promise.resolve();
62
+ lastKeyFileWrite = 0;
63
+ fetchImpl;
64
+ now;
65
+ sleep;
66
+ awaitMs;
67
+ constructor(deps) {
68
+ this.deps = deps;
69
+ this.credential = deps.initial;
70
+ this.fetchImpl = deps.fetch ?? fetch;
71
+ this.now = deps.now ?? Date.now;
72
+ this.sleep = deps.sleep ?? defaultSleep;
73
+ this.awaitMs = deps.awaitMs ?? AWAIT_APPROVAL_MS;
74
+ deps.setToken(deps.initial?.token ?? "");
75
+ }
76
+ isSignedIn() {
77
+ return this.credential !== null;
78
+ }
79
+ /** Where the current credential comes from and its fingerprint, for the bridge's connection record. */
80
+ connection() {
81
+ if (this.credential === null)
82
+ return null;
83
+ return {
84
+ dashboardUrl: this.deps.dashboardUrl.replace(/\/+$/, ""),
85
+ source: this.credential.source,
86
+ fingerprint: this.credential.fingerprint,
87
+ };
88
+ }
89
+ /**
90
+ * The dashboard answered 401 to this session's key: drop it. A dispatch credential is never
91
+ * dropped (the dispatch owns it). True when this signed the session out.
92
+ */
93
+ dropKey() {
94
+ if (this.credential === null || this.credential.source.kind !== "session-key")
95
+ return false;
96
+ deleteSessionKey(this.credential.source.path);
97
+ this.credential = null;
98
+ this.pending = null; // DX-4390: a settled request of the dropped key must not read as "expired"
99
+ this.deps.setToken("");
100
+ return true;
101
+ }
102
+ /**
103
+ * Run a dashboard call for a tool: halted while signed out, and signed out (key file deleted)
104
+ * when the dashboard answers 401 to this session's key.
105
+ */
106
+ async guard(run) {
107
+ if (this.credential === null)
108
+ return { halt: SIGN_IN_HALT };
109
+ const result = await run();
110
+ if (result.status === 401 && this.dropKey())
111
+ return { halt: REVOKED_HALT };
112
+ this.keepKeyFile();
113
+ return { result };
114
+ }
115
+ /**
116
+ * DX-4390: the stored file is the bridge's only way to the key, and the 7-day prune ages out
117
+ * a file by when it was last written. A server in use rewrites it (atomically) at most hourly,
118
+ * which also restores a file someone deleted while this process still holds the key.
119
+ */
120
+ keepKeyFile() {
121
+ const credential = this.credential;
122
+ if (credential === null || credential.source.kind !== "session-key")
123
+ return;
124
+ const now = this.now();
125
+ if (now - this.lastKeyFileWrite < KEEP_KEY_FILE_EVERY_MS && existsSync(credential.source.path))
126
+ return;
127
+ try {
128
+ writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey: credential.token }, { home: this.deps.home, now });
129
+ this.lastKeyFileWrite = now;
130
+ }
131
+ catch (err) {
132
+ this.deps.log?.(`[danx-dashboard-mcp] could not refresh the session key file: ${err.message}`);
133
+ }
134
+ }
135
+ /**
136
+ * Make sure this session holds a key. Signed in: `{signedIn: true}`. Otherwise the answer
137
+ * `plan_connect` returns: the request just made, the one still pending (after waiting up to
138
+ * `awaitMs` for it), or why there is none. Calls run one at a time.
139
+ */
140
+ ensureAccess(title) {
141
+ const run = this.ensureQueue.then(() => this.ensureAccessNow(title));
142
+ this.ensureQueue = run.catch(() => undefined);
143
+ return run;
144
+ }
145
+ async ensureAccessNow(title) {
146
+ if (this.credential !== null)
147
+ return { signedIn: true };
148
+ const sessionId = this.deps.sessionId;
149
+ if (sessionId === undefined || sessionId === "") {
150
+ return notReady({ state: "no_session_id", error: NO_SESSION_ID_ERROR }, true);
151
+ }
152
+ let note;
153
+ if (this.pending !== null) {
154
+ const pending = this.pending;
155
+ const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
156
+ if (outcome === null)
157
+ return notReady(this.requestAnswer("approval_pending", pending.request), false);
158
+ this.pending = null;
159
+ switch (outcome.kind) {
160
+ case "approved":
161
+ return { signedIn: true };
162
+ case "denied":
163
+ return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
164
+ case "failed":
165
+ return notReady({ state: "request_failed", error: outcome.error }, true);
166
+ case "expired":
167
+ note = EXPIRED_NOTE;
168
+ }
169
+ }
170
+ return this.createRequest(sessionId, title, note);
171
+ }
172
+ async createRequest(sessionId, title, note) {
173
+ const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
174
+ kind: "session",
175
+ labelId: sessionId,
176
+ title: title ?? sessionId.slice(0, TITLE_FALLBACK_LENGTH),
177
+ repo: this.deps.repo,
178
+ machine: this.deps.machine ?? hostname(),
179
+ clientVersion: this.deps.clientVersion,
180
+ });
181
+ switch (created.kind) {
182
+ case "dashboard_outdated":
183
+ return notReady({ state: "dashboard_outdated", error: DASHBOARD_OUTDATED_ERROR }, true);
184
+ case "rate_limited":
185
+ return notReady({ state: "rate_limited", retryAfterSeconds: created.retryAfterSeconds, error: RATE_LIMITED_ERROR }, true);
186
+ case "refused":
187
+ return notReady({ state: "request_refused", status: created.status, body: created.body }, true);
188
+ case "created":
189
+ this.pending = { request: created.request, settled: this.pollUntilSettled(created.request) };
190
+ return notReady({ ...this.requestAnswer("approval_required", created.request), ...(note ? { note } : {}) }, false);
191
+ }
192
+ }
193
+ requestAnswer(state, request) {
194
+ return {
195
+ state,
196
+ approvalUrl: request.approvalUrl,
197
+ confirmCode: request.confirmCode,
198
+ expiresAt: request.expiresAt,
199
+ instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
200
+ };
201
+ }
202
+ async pollUntilSettled(request) {
203
+ const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
204
+ let waitMs = request.pollMs;
205
+ for (;;) {
206
+ await this.sleep(waitMs);
207
+ waitMs = request.pollMs;
208
+ if (this.now() > giveUpAt)
209
+ return { kind: "expired" };
210
+ let claim;
211
+ try {
212
+ claim = await claimAccessRequest(this.fetchImpl, this.deps.dashboardUrl, request.secret);
213
+ }
214
+ catch (err) {
215
+ // A claim the dashboard could not answer is retried until the request lapses; an outdated
216
+ // MCP will never get a different answer, so that one ends it.
217
+ if (err instanceof McpOutdatedError)
218
+ return { kind: "failed", error: err.message };
219
+ this.deps.log?.(`[danx-dashboard-mcp] access request poll failed, retrying: ${err.message}`);
220
+ continue;
221
+ }
222
+ switch (claim.kind) {
223
+ case "pending":
224
+ continue;
225
+ case "rate_limited":
226
+ waitMs = Math.max(request.pollMs, claim.retryAfterSeconds * 1000);
227
+ continue;
228
+ case "approved":
229
+ return this.storeKey(claim.apiKey);
230
+ case "denied":
231
+ return { kind: "denied" };
232
+ case "expired":
233
+ case "not_found":
234
+ return { kind: "expired" };
235
+ case "claimed":
236
+ return { kind: "failed", error: CLAIMED_LOST_ERROR };
237
+ case "refused":
238
+ return {
239
+ kind: "failed",
240
+ error: `the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`,
241
+ };
242
+ }
243
+ }
244
+ }
245
+ storeKey(apiKey) {
246
+ const now = this.now();
247
+ try {
248
+ const path = writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey }, { home: this.deps.home, now });
249
+ this.credential = {
250
+ source: { kind: "session-key", path },
251
+ token: apiKey,
252
+ fingerprint: credentialFingerprint(apiKey),
253
+ };
254
+ this.lastKeyFileWrite = now;
255
+ this.deps.setToken(apiKey);
256
+ return { kind: "approved" };
257
+ }
258
+ catch (err) {
259
+ return {
260
+ kind: "failed",
261
+ error: `the request was approved but storing the key failed: ${err.message}; start a new request`,
262
+ };
263
+ }
264
+ }
265
+ }
@@ -10,7 +10,7 @@
10
10
  * the server's, every event was dropped silently (the card's incident).
11
11
  *
12
12
  * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
13
- * writes the dashboard URL, the credential SOURCE (a file path, or the name of
13
+ * writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
14
14
  * an env var — never a credential) and the credential's FINGERPRINT. The bridge
15
15
  * reads that record, resolves the SAME source through the same resolver
16
16
  * (`credential.ts`), and refuses to run unless what it resolved fingerprints
@@ -36,7 +36,9 @@ import { join, resolve, win32 } from "node:path";
36
36
  // DX-3928 — v2 adds `projectKey`, the directory the connecting session ran in, so a
37
37
  // restarted session (a NEW session id, no record of its own) can find which plan the
38
38
  // previous session of the same project was on (`findPredecessorCandidates`).
39
- export const SESSION_CONNECTION_SCHEMA_VERSION = 2;
39
+ export const SESSION_CONNECTION_SCHEMA_VERSION = 3;
40
+ // DX-4390 — v3: the credential source is `session-key` (an approved per-session key file) or `env`;
41
+ // the v2 `file` source (a hand-placed token file) no longer exists.
40
42
  export const STALE_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
41
43
  /**
42
44
  * DX-3928 — the one definition of "the same project": the resolved working
@@ -54,15 +56,21 @@ export function projectKeyOf(cwd) {
54
56
  export function sessionConnectionDir(home = homedir()) {
55
57
  return join(home, ".config", "danxbot", "plan-sessions");
56
58
  }
57
- /** A session id is a path segment here, so an odd one is refused rather than turned into a path. */
58
- export function sessionConnectionPath(sessionId, home = homedir()) {
59
+ /**
60
+ * A session id is a path segment (here and in `session-key.ts`), so an odd one is refused rather than
61
+ * turned into a path.
62
+ */
63
+ export function assertSessionIdSegment(sessionId) {
59
64
  if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]+$/.test(sessionId)) {
60
65
  throw new Error(`session id is missing or has unexpected characters: ${JSON.stringify(sessionId)}`);
61
66
  }
67
+ }
68
+ export function sessionConnectionPath(sessionId, home = homedir()) {
69
+ assertSessionIdSegment(sessionId);
62
70
  return join(sessionConnectionDir(home), `${sessionId}.json`);
63
71
  }
64
- /** Remove records nothing has touched for `STALE_RECORD_MS`. */
65
- export function pruneStaleConnections(dir, now) {
72
+ /** Remove the per-session `.json` files in `dir` that nothing has touched for `STALE_RECORD_MS`. */
73
+ export function pruneStaleSessionFiles(dir, now) {
66
74
  let names;
67
75
  try {
68
76
  names = readdirSync(dir);
@@ -93,7 +101,7 @@ export function writeSessionConnection(record, options = {}) {
93
101
  const file = sessionConnectionPath(record.sessionId, home);
94
102
  const dir = sessionConnectionDir(home);
95
103
  mkdirSync(dir, { recursive: true });
96
- pruneStaleConnections(dir, now);
104
+ pruneStaleSessionFiles(dir, now);
97
105
  const full = {
98
106
  schemaVersion: SESSION_CONNECTION_SCHEMA_VERSION,
99
107
  ...record,
@@ -196,7 +204,7 @@ export function invalidRecordReason(value) {
196
204
  const source = r.source;
197
205
  if (typeof source !== "object" || source === null)
198
206
  return "source is not an object";
199
- if (source.kind === "file")
207
+ if (source.kind === "session-key")
200
208
  return typeof source.path === "string" && source.path !== "" ? null : "source.path is not a path";
201
209
  if (source.kind === "env")
202
210
  return typeof source.name === "string" && source.name !== "" ? null : "source.name is not a variable name";
@@ -0,0 +1,111 @@
1
+ /**
2
+ * DX-4390 (PLAN-17 section 96) - the dashboard key a user approved for ONE agent session, kept on
3
+ * this machine so the session's MCP server and its event bridge both authenticate with it.
4
+ *
5
+ * One file per dashboard and per session:
6
+ * `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600. The key
7
+ * lapses on the dashboard after 24 hours without use (it slides on every authenticated call), so
8
+ * this file is a cache of a revocable, short-lived credential, not a standing secret: a 401 on it
9
+ * deletes it (`SessionAccess`), and files nothing has touched for a week are pruned on the next
10
+ * write (`pruneStaleSessionFiles`, shared with the session connection record). A live server
11
+ * rewrites its file at least hourly (`SessionAccess.guard`), so only an unused session's file
12
+ * ages out. The session id picks the file but never selects a key on the dashboard.
13
+ *
14
+ * Written write-then-rename, like the session connection record, so a bridge reading
15
+ * concurrently sees the old file or the new one, never a torn one.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { assertSessionIdSegment, pruneStaleSessionFiles } from "./session-connection.js";
22
+ /** One canonical shape (CLAUDE.md Core Principle 1): a file of any other version is refused. */
23
+ export const SESSION_KEY_SCHEMA_VERSION = 1;
24
+ const dashboardHash = (dashboardUrl) => createHash("sha256").update(dashboardUrl.replace(/\/+$/, "")).digest("hex").slice(0, 12);
25
+ export function sessionKeyRoot(home = homedir()) {
26
+ return join(home, ".config", "danxbot", "session-keys");
27
+ }
28
+ export function sessionKeyDir(dashboardUrl, home = homedir()) {
29
+ return join(sessionKeyRoot(home), dashboardHash(dashboardUrl));
30
+ }
31
+ export function sessionKeyPath(dashboardUrl, sessionId, home = homedir()) {
32
+ assertSessionIdSegment(sessionId);
33
+ return join(sessionKeyDir(dashboardUrl, home), `${sessionId}.json`);
34
+ }
35
+ /** Store a freshly claimed key for this session; returns the file's path. */
36
+ export function writeSessionKey(input, options = {}) {
37
+ const home = options.home ?? homedir();
38
+ const now = options.now ?? Date.now();
39
+ const file = sessionKeyPath(input.dashboardUrl, input.sessionId, home);
40
+ const dir = sessionKeyDir(input.dashboardUrl, home);
41
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
42
+ pruneStaleSessionFiles(dir, now);
43
+ const record = {
44
+ schemaVersion: SESSION_KEY_SCHEMA_VERSION,
45
+ dashboardUrl: input.dashboardUrl.replace(/\/+$/, ""),
46
+ sessionId: input.sessionId,
47
+ apiKey: input.apiKey,
48
+ savedAt: new Date(now).toISOString(),
49
+ };
50
+ const tmp = `${file}.${process.pid}.${now}.tmp`;
51
+ writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
52
+ renameSync(tmp, file);
53
+ return file;
54
+ }
55
+ /** Why a key file cannot be used, or `null`. */
56
+ function invalidKeyReason(value) {
57
+ if (typeof value !== "object" || value === null)
58
+ return "it is not an object";
59
+ const r = value;
60
+ if (r.schemaVersion !== SESSION_KEY_SCHEMA_VERSION) {
61
+ return `schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_KEY_SCHEMA_VERSION}`;
62
+ }
63
+ for (const field of ["dashboardUrl", "sessionId", "apiKey", "savedAt"]) {
64
+ if (typeof r[field] !== "string" || r[field] === "")
65
+ return `${field} is not a non-empty string`;
66
+ }
67
+ return null;
68
+ }
69
+ /** The thrown shape of an absent file, so a caller can tell "no key" from "a key I cannot read". */
70
+ export class SessionKeyAbsentError extends Error {
71
+ constructor(path) {
72
+ super(`there is no session key at ${path}`);
73
+ this.name = "SessionKeyAbsentError";
74
+ }
75
+ }
76
+ /** A key file that is there but unreadable or of another shape. */
77
+ export class SessionKeyUnusableError extends Error {
78
+ path;
79
+ constructor(path, why) {
80
+ super(`the session key file ${path} is unusable: ${why}`);
81
+ this.path = path;
82
+ this.name = "SessionKeyUnusableError";
83
+ }
84
+ }
85
+ /** The key in `path`. Absent and unusable are different errors: only the first means "never signed in". */
86
+ export function readSessionKeyFile(path) {
87
+ let raw;
88
+ try {
89
+ raw = readFileSync(path, "utf-8");
90
+ }
91
+ catch (err) {
92
+ if (err.code === "ENOENT")
93
+ throw new SessionKeyAbsentError(path);
94
+ throw new SessionKeyUnusableError(path, err.message);
95
+ }
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(raw);
99
+ }
100
+ catch (err) {
101
+ throw new SessionKeyUnusableError(path, `not JSON (${err.message})`);
102
+ }
103
+ const invalid = invalidKeyReason(parsed);
104
+ if (invalid !== null)
105
+ throw new SessionKeyUnusableError(path, invalid);
106
+ return parsed.apiKey;
107
+ }
108
+ /** Delete a key file (a revoked or lapsed key). Never throws for an absent file. */
109
+ export function deleteSessionKey(path) {
110
+ rmSync(path, { force: true });
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.223",
3
+ "version": "0.1.224",
4
4
  "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",