@thehammer/danx-dashboard-mcp 0.1.224 → 0.1.225

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
@@ -20,7 +20,7 @@ BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from boa
20
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
21
 
22
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.
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). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent ends with `danxbot_complete`), for the rest of the process. No access request is made. `danxbot_complete` and the other worker lifecycle tools do not use the dashboard key, so a dispatched agent can still end. The session's event bridge ends with the stop reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
24
24
  - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
25
25
 
26
26
  ## Tool surface (DX-3465 — one generic tool, not one per operation)
package/dist/bridge.js CHANGED
@@ -69,6 +69,7 @@
69
69
  import { randomUUID } from "node:crypto";
70
70
  import { homedir } from "node:os";
71
71
  import { errorCodeOf } from "./json-body.js";
72
+ import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
72
73
  import { oneLine } from "./one-line.js";
73
74
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
74
75
  import { readSessionConnection, sessionConnectionPath } from "./session-connection.js";
@@ -220,7 +221,9 @@ export const STOP_FIXES = {
220
221
  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
222
  not_connected: "call plan_connect to connect this session to a plan",
222
223
  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: "the dashboard no longer accepts this session's key (revoked, or lapsed after a day unused): call plan_connect again to request access",
224
+ unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
225
+ key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
226
+ "do not call plan_connect or request access again",
224
227
  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
228
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
226
229
  // A newer ticket exists (superseded) or another connection is already using
@@ -439,6 +442,12 @@ export async function mintTicket(options, deps) {
439
442
  if (status === 409 && errorCodeOf(body) === "session_is_worker") {
440
443
  return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
441
444
  }
445
+ // DX-4418 - a key a person revoked is not a lapsed one: the detail IS the stop halt (who revoked it, what to do), so the record
446
+ // the plugin relays to the session says it in full.
447
+ const revocation = keyRevocationOf({ ok: false, status, body });
448
+ if (revocation !== null) {
449
+ return { kind: "terminal", reason: "key_revoked", detail: keyRevokedHalt(revocation, false) };
450
+ }
442
451
  if (status === 401 || status === 403) {
443
452
  return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
444
453
  }
@@ -545,6 +554,8 @@ const EXIT_NEVER_RESTART_LIST = [
545
554
  "superseded",
546
555
  "replaced",
547
556
  "session_is_worker",
557
+ // DX-4418 - a person revoked the key: the session stops for good, nothing retries.
558
+ "key_revoked",
548
559
  ];
549
560
  const EXIT_NEVER_RESTART = new Set(EXIT_NEVER_RESTART_LIST);
550
561
  const DEGRADE_NO_HEARTBEAT_LIST = ["credential_unavailable", "credential_mismatch"];
@@ -557,8 +568,9 @@ export function degradePolicyFor(reason) {
557
568
  return "degrade-server-fixable";
558
569
  }
559
570
  /**
560
- * One heartbeat POST. NEVER throws and NEVER stops the bridge on failure
561
- * (card AC1 — "a failed heartbeat never stops the bridge"): `request()`
571
+ * One heartbeat POST. Never throws on a failure of the request itself and NEVER stops the bridge on one
572
+ * (the one throw is a contract breach: a `key_revoked` answer that does not say who revoked the key, DX-4418; `heartbeatTick`'s
573
+ * `.catch` keeps it from taking the bridge down) (card AC1 — "a failed heartbeat never stops the bridge"): `request()`
562
574
  * itself already classifies every network/timeout/5xx failure as
563
575
  * `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
564
576
  * credential) simply comes back as an ordinary non-2xx answer this function
@@ -592,7 +604,7 @@ async function sendHeartbeat(options, deps, connection, stats) {
592
604
  // request never landed at all, which is exactly the signal the silent-
593
605
  // failure counter in `runBridge` below needs. Still never throws.
594
606
  if (outcome.kind !== "answer")
595
- return { landed: false, status: null, stats: null };
607
+ return { landed: false, status: null, stats: null, revocation: null };
596
608
  // DX-2954 — the answer's `stats` (the operator dispatch's stored or
597
609
  // just-written totals and cursors) feed the session's stats collector.
598
610
  const ok = outcome.status >= 200 && outcome.status < 300;
@@ -610,7 +622,8 @@ async function sendHeartbeat(options, deps, connection, stats) {
610
622
  Object.values(statsBody.sourceCursors).every((cursor) => isRecord(cursor) && isClaimState(cursor.claims))
611
623
  ? statsBody
612
624
  : null;
613
- return { landed: true, status: outcome.status, stats: answered };
625
+ // DX-4418 - the dashboard refuses the beat of a session whose key a person revoked, and says so.
626
+ return { landed: true, status: outcome.status, stats: answered, revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }) };
614
627
  }
615
628
  function isRecord(value) {
616
629
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -643,6 +656,8 @@ export async function runBridge(options, deps) {
643
656
  // `onConnectionAttempt` below; calling it after that attempt has already
644
657
  // ended is a harmless no-op.
645
658
  let heartbeatFailureStreak = 0;
659
+ // DX-4418 - the stop notice for a revoked key goes out once; every later beat is refused the same way.
660
+ let revocationAnnounced = false;
646
661
  let forceReconnect = null;
647
662
  // DX-2954 — the session's stats ride this same heartbeat. Created ONCE per
648
663
  // process, so an in-process re-mint never touches its state.
@@ -657,7 +672,16 @@ export async function runBridge(options, deps) {
657
672
  const heartbeatTick = () => {
658
673
  if (heartbeatSuppressed)
659
674
  return;
660
- void sessionStats.tick((stats) => sendHeartbeat(options, deps, connectionNow(), stats)).then((landed) => {
675
+ void sessionStats.tick(async (stats) => {
676
+ const answer = await sendHeartbeat(options, deps, connectionNow(), stats);
677
+ // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once, as an ordinary event
678
+ // line (the same delivery the silent-failure notice below uses). Its next dashboard call says the same.
679
+ if (answer.revocation !== null && !revocationAnnounced) {
680
+ revocationAnnounced = true;
681
+ routeEvent({ type: "event", id: null, text: keyRevokedHalt(answer.revocation, false), origin: null });
682
+ }
683
+ return answer;
684
+ }).then((landed) => {
661
685
  // DX-3099 — only counts while THIS process still believes it is
662
686
  // streaming: that is the exact desync the card found (the client
663
687
  // believes it is fine; the dashboard has nothing recent for it). A
package/dist/index.js CHANGED
@@ -279,6 +279,9 @@ function haltResult(text) {
279
279
  function accessReply(ready, note) {
280
280
  if (ready.signedIn)
281
281
  return jsonResult({ state: "signed_in", ...(note ? { note } : {}) });
282
+ // DX-4418: a person revoked this session's access: the one stop halt, verbatim, never a request.
283
+ if (ready.halt !== undefined)
284
+ return haltResult(ready.halt);
282
285
  const answer = note ? { ...ready.answer, note } : ready.answer;
283
286
  return ready.isError ? haltResult(JSON.stringify(answer, null, 2)) : jsonResult(answer);
284
287
  }
@@ -502,6 +505,10 @@ async (args) => {
502
505
  });
503
506
  }
504
507
  const result = await planConnect(client, { ...args, plan_id: args.plan_id });
508
+ // DX-4418 — a person revoked this key: the stop halt, and nothing asks again.
509
+ const revokedHalt = access.refusalHalt(result);
510
+ if (revokedHalt !== null)
511
+ return haltResult(revokedHalt);
505
512
  // DX-4390 — the dashboard no longer accepts the stored key: drop it and ask again in the same call.
506
513
  if (result.status === 401 && access.dropKey()) {
507
514
  return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The revocation a `401 key_revoked` result carries, or null for any other result. A `key_revoked` refusal that does not
3
+ * say who revoked it and when is a contract breach and throws, like a terms refusal without its link.
4
+ */
5
+ export function keyRevocationOf(result) {
6
+ if (result.ok || result.status !== 401)
7
+ return null;
8
+ const body = result.body;
9
+ if (body?.error !== "key_revoked")
10
+ return null;
11
+ if (typeof body.revokedBy !== "string" || typeof body.revokedAt !== "string") {
12
+ throw new Error("[danx-dashboard-mcp] a key_revoked refusal carried no revokedBy / revokedAt");
13
+ }
14
+ return { revokedBy: body.revokedBy, revokedAt: body.revokedAt };
15
+ }
16
+ /**
17
+ * The one halt. `dispatched` is a dispatch's own credential: it ends with `danxbot_complete` (a worker tool, which does not
18
+ * use the revoked key), and the worker records the end as revoked, not as the agent's failure. An operator session ends by
19
+ * telling the person; it has no tool that ends it.
20
+ */
21
+ export function keyRevokedHalt(revocation, dispatched) {
22
+ const ending = dispatched
23
+ ? 'End this dispatch with danxbot_complete({status: "failed", summary: "<where your work is committed>"}); the worker records it as revoked, not as a failure.'
24
+ : "Then end this session: tell the person what you committed and stop.";
25
+ return (`STOP ALL WORK NOW. ${revocation.revokedBy} revoked your access to the danxbot dashboard at ${revocation.revokedAt}.\n\n` +
26
+ "Commit your work in progress now (commit what you have; do not run agent-finalize.sh and do not merge). " +
27
+ `${ending}\n\n` +
28
+ "Do not call plan_connect or any other danxbot dashboard tool again, do not request access again and do not look for another route.");
29
+ }
@@ -8,6 +8,11 @@
8
8
  * (`session-key.ts`) and a 401 on it deletes the file (`dropKey`).
9
9
  * - signed out no credential. Every tool but `plan_connect` halts; `plan_connect` asks.
10
10
  *
11
+ * DX-4418: a fourth state, REVOKED, ends all three for good. The dashboard answers `401 key_revoked` to a key a person revoked
12
+ * (as opposed to one that lapsed); this object then remembers it for the life of the process, every dashboard tool (and
13
+ * `plan_connect`) returns the one stop halt (`key-revoked-halt.ts`), and no new access request is made. A session's key file is
14
+ * deleted; a dispatch's credential is left to its dispatch, but the halt is the same.
15
+ *
11
16
  * ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `plan_connect` posts the request and
12
17
  * answers at once with the approval URL and confirm code. From then on THIS object polls claim in
13
18
  * the background, so the key is stored the moment the user approves, and the next `plan_connect`
@@ -18,6 +23,7 @@ import { existsSync } from "node:fs";
18
23
  import { hostname } from "node:os";
19
24
  import { claimAccessRequest, createAccessRequest } from "./access-request.js";
20
25
  import { credentialFingerprint } from "./credential.js";
26
+ import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
21
27
  import { deleteSessionKey, writeSessionKey } from "./session-key.js";
22
28
  import { McpOutdatedError } from "./version-floor.js";
23
29
  /** Longest a `plan_connect` call waits for an approval already asked for. */
@@ -30,8 +36,8 @@ const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
30
36
  const TITLE_FALLBACK_LENGTH = 8;
31
37
  export const SIGN_IN_HALT = "Not signed in to the danxbot dashboard. Call `plan_connect` (with `title`: your session's own " +
32
38
  "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. " +
39
+ /** DX-4418: the session's key lapsed (a day unused) or the dashboard does not know it; a PERSON's revoke is `keyRevokedHalt` instead. */
40
+ export const KEY_LAPSED_HALT = "The dashboard no longer accepts this session's key (it lapsed after a day unused), so this session is signed out. " +
35
41
  SIGN_IN_HALT;
36
42
  const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
37
43
  "matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval.";
@@ -48,14 +54,17 @@ const EXPIRED_NOTE = "The previous request expired before it was approved.";
48
54
  const defaultSleep = (ms) => new Promise((resolve) => {
49
55
  setTimeout(resolve, ms).unref();
50
56
  });
51
- const notReady = (answer, isError) => ({
57
+ const notReady = (answer, isError, halt) => ({
52
58
  signedIn: false,
53
59
  answer,
54
60
  isError,
61
+ ...(halt === undefined ? {} : { halt }),
55
62
  });
56
63
  export class SessionAccess {
57
64
  deps;
58
65
  credential;
66
+ // DX-4418: set once the dashboard says a person revoked the key; never cleared (a restarted server is a new process).
67
+ revoked = null;
59
68
  pending = null;
60
69
  // DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
61
70
  ensureQueue = Promise.resolve();
@@ -74,11 +83,27 @@ export class SessionAccess {
74
83
  deps.setToken(deps.initial?.token ?? "");
75
84
  }
76
85
  isSignedIn() {
77
- return this.credential !== null;
86
+ return this.credential !== null && this.revoked === null;
87
+ }
88
+ /** DX-4418: the stop halt once a person revoked the key, else null. */
89
+ revokedHalt() {
90
+ return this.revoked === null ? null : keyRevokedHalt(this.revoked.revocation, this.revoked.dispatched);
91
+ }
92
+ /**
93
+ * DX-4418: a dashboard answer that says a person revoked the key behind it: remembered for good (a session's key file is
94
+ * deleted, a dispatch's credential is left to its dispatch) and answered as the stop halt. Null for every other answer.
95
+ */
96
+ refusalHalt(result) {
97
+ const revocation = keyRevocationOf(result);
98
+ if (revocation === null)
99
+ return null;
100
+ this.revoked ??= { revocation, dispatched: this.credential?.source.kind === "env" };
101
+ this.dropKey();
102
+ return this.revokedHalt();
78
103
  }
79
104
  /** Where the current credential comes from and its fingerprint, for the bridge's connection record. */
80
105
  connection() {
81
- if (this.credential === null)
106
+ if (this.credential === null || this.revoked !== null)
82
107
  return null;
83
108
  return {
84
109
  dashboardUrl: this.deps.dashboardUrl.replace(/\/+$/, ""),
@@ -104,11 +129,17 @@ export class SessionAccess {
104
129
  * when the dashboard answers 401 to this session's key.
105
130
  */
106
131
  async guard(run) {
132
+ const revokedHalt = this.revokedHalt();
133
+ if (revokedHalt !== null)
134
+ return { halt: revokedHalt };
107
135
  if (this.credential === null)
108
136
  return { halt: SIGN_IN_HALT };
109
137
  const result = await run();
138
+ const refused = this.refusalHalt(result);
139
+ if (refused !== null)
140
+ return { halt: refused };
110
141
  if (result.status === 401 && this.dropKey())
111
- return { halt: REVOKED_HALT };
142
+ return { halt: KEY_LAPSED_HALT };
112
143
  this.keepKeyFile();
113
144
  return { result };
114
145
  }
@@ -143,6 +174,10 @@ export class SessionAccess {
143
174
  return run;
144
175
  }
145
176
  async ensureAccessNow(title) {
177
+ // DX-4418: a person revoked this session's access; it never asks again.
178
+ const revokedHalt = this.revokedHalt();
179
+ if (revokedHalt !== null)
180
+ return notReady({ state: "access_revoked", instruction: revokedHalt }, true, revokedHalt);
146
181
  if (this.credential !== null)
147
182
  return { signedIn: true };
148
183
  const sessionId = this.deps.sessionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.224",
3
+ "version": "0.1.225",
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",