@thehammer/danx-dashboard-mcp 0.1.223 → 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 +10 -2
- package/dist/access-request.js +90 -0
- package/dist/bridge.js +33 -9
- package/dist/credential.js +60 -80
- package/dist/http-client.js +11 -0
- package/dist/index.js +107 -41
- package/dist/key-revoked-halt.js +29 -0
- package/dist/session-access.js +300 -0
- package/dist/session-connection.js +16 -8
- package/dist/session-key.js +111 -0
- package/package.json +1 -1
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
|
-
| `
|
|
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). 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
|
+
- 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
|
@@ -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";
|
|
@@ -216,11 +217,13 @@ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${B
|
|
|
216
217
|
export const STOP_FIXES = {
|
|
217
218
|
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
|
|
218
219
|
"(a server older than the one that introduced the record writes none)",
|
|
219
|
-
credential_unavailable: "
|
|
220
|
+
credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
|
|
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: "
|
|
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
|
|
@@ -300,13 +303,13 @@ function resolveInstanceId(env) {
|
|
|
300
303
|
}
|
|
301
304
|
/**
|
|
302
305
|
* DX-3028 — the on-disk paths this bridge's authentication depends on, for
|
|
303
|
-
* `BridgeOptions.watchedPaths` (see its docblock). A `
|
|
306
|
+
* `BridgeOptions.watchedPaths` (see its docblock). A `session-key`
|
|
304
307
|
* credential adds its own path; an `env`-sourced one has no file
|
|
305
308
|
* (`credential.ts` ~47-48, ~90-95).
|
|
306
309
|
*/
|
|
307
310
|
function watchedPathsFor(record, home) {
|
|
308
311
|
const paths = [sessionConnectionPath(record.sessionId, home)];
|
|
309
|
-
if (record.source.kind === "
|
|
312
|
+
if (record.source.kind === "session-key")
|
|
310
313
|
paths.push(record.source.path);
|
|
311
314
|
return paths;
|
|
312
315
|
}
|
|
@@ -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.
|
|
561
|
-
* (
|
|
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
|
-
|
|
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(
|
|
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/credential.js
CHANGED
|
@@ -1,50 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DX-2862
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
12
|
+
* A credential comes from exactly one of two places:
|
|
19
13
|
*
|
|
20
|
-
* - `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
46
|
-
*
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
61
|
+
return readSessionKeyFile(source.path);
|
|
101
62
|
}
|
|
102
63
|
catch (err) {
|
|
103
|
-
throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`,
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
}
|
package/dist/http-client.js
CHANGED
|
@@ -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,
|
|
53
|
-
*
|
|
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 {
|
|
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-
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
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
|
|
139
|
+
const dashboardUrl = readEnvOrDie("DANXBOT_DASHBOARD_URL");
|
|
140
|
+
const repo = readEnvOrDie("DANX_REPO_NAME");
|
|
141
|
+
const session = readSessionConfig();
|
|
139
142
|
config = {
|
|
140
|
-
baseUrl:
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
175
|
-
* also uses, so a session's tools and its event stream can never sign in as
|
|
176
|
-
* different identities.
|
|
177
|
-
*
|
|
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
|
|
188
|
+
function resolveInitialCredential(dashboardUrl, sessionId) {
|
|
180
189
|
try {
|
|
181
|
-
return
|
|
190
|
+
return resolveBootCredential(process.env, { dashboardUrl, sessionId });
|
|
182
191
|
}
|
|
183
192
|
catch (err) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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,24 @@ 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
|
+
// 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);
|
|
285
|
+
const answer = note ? { ...ready.answer, note } : ready.answer;
|
|
286
|
+
return ready.isError ? haltResult(JSON.stringify(answer, null, 2)) : jsonResult(answer);
|
|
287
|
+
}
|
|
288
|
+
/** DX-4390 — a tool's dashboard call behind the sign-in halt (and the 401 sign-out). */
|
|
289
|
+
async function guardedJson(run) {
|
|
290
|
+
const guarded = await access.guard(run);
|
|
291
|
+
return "halt" in guarded ? haltResult(guarded.halt) : jsonResult(guarded.result);
|
|
292
|
+
}
|
|
264
293
|
function jsonResult(value) {
|
|
265
294
|
return {
|
|
266
295
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -371,7 +400,10 @@ async (args) => {
|
|
|
371
400
|
const refused = termsAcceptanceCallHalt(args, config.baseUrl);
|
|
372
401
|
if (refused)
|
|
373
402
|
return haltResult(refused);
|
|
374
|
-
const
|
|
403
|
+
const guarded = await access.guard(() => danxbotApi(client, args));
|
|
404
|
+
if ("halt" in guarded)
|
|
405
|
+
return haltResult(guarded.halt);
|
|
406
|
+
const result = guarded.result;
|
|
375
407
|
const halt = termsHaltFor(result);
|
|
376
408
|
if (halt)
|
|
377
409
|
return haltResult(halt);
|
|
@@ -382,7 +414,7 @@ strictTool("danxbot_api_list",
|
|
|
382
414
|
// DX-3528 — the list is filtered by the same permission check every request takes.
|
|
383
415
|
"Lists the dashboard API routes your credential may call: `[{method, path, summary, permission}]`, each " +
|
|
384
416
|
"`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 () =>
|
|
417
|
+
"listed is not callable by you.", {}, async () => guardedJson(() => danxbotApiList(client)));
|
|
386
418
|
// ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
|
|
387
419
|
strictTool("danxbot_api_spec",
|
|
388
420
|
// DX-3527 — the contract is generated from the route table.
|
|
@@ -391,7 +423,7 @@ strictTool("danxbot_api_spec",
|
|
|
391
423
|
'(e.g. `"POST /api/issues/:id/transition"`), never a real path with ids. An endpoint the dashboard does ' +
|
|
392
424
|
"not serve, or your credential may not call, is refused and named.", {
|
|
393
425
|
endpoints: z.array(z.string().min(1)).min(1),
|
|
394
|
-
}, async (args) =>
|
|
426
|
+
}, async (args) => guardedJson(() => danxbotApiSpec(client, args)));
|
|
395
427
|
// ---------------- attach_file (DX-3465 A4, checklist item 36140) ----------------
|
|
396
428
|
//
|
|
397
429
|
// The "25 MB" ceiling quoted below tracks the backend route's
|
|
@@ -404,8 +436,9 @@ strictTool("danxbot_api_spec",
|
|
|
404
436
|
// might hold locally, e.g. the Slack recovery agent's CSV path).
|
|
405
437
|
strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and uploads the bytes, so they never pass through your " +
|
|
406
438
|
"context. The attachment mirrors to the card's linked Trello card and Slack thread. 25 MB limit (413). " +
|
|
407
|
-
"Returns `{issue, attachment_id,
|
|
408
|
-
"markdown field as `` and it renders
|
|
439
|
+
"Returns `{issue, attachment_id, s3_key}`: the entry of `issue.attachments` with that `attachment_id` " +
|
|
440
|
+
"carries a permanent `url`: embed it in any markdown field as `` and it renders " +
|
|
441
|
+
"inline. Use it when a screenshot or file judges something faster than prose, or instead of pasting a " +
|
|
409
442
|
"large CSV or log into a card field.", {
|
|
410
443
|
card_id: z.string().min(1).describe("The card id to attach the file to."),
|
|
411
444
|
path: z
|
|
@@ -415,7 +448,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
415
448
|
filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
|
|
416
449
|
content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
|
|
417
450
|
...boardField,
|
|
418
|
-
}, async (args) =>
|
|
451
|
+
}, async (args) => guardedJson(() => attachFile(client, args)));
|
|
419
452
|
// ---------------- plans (DX-2683) ----------------
|
|
420
453
|
// THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
|
|
421
454
|
// takes a plan id in its path; the plan records, plan cards and
|
|
@@ -429,14 +462,22 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
429
462
|
strictTool("plan_connect",
|
|
430
463
|
// DX-4318 — states only what to do with the reply. What it used to add arrives where it is used: a rejected
|
|
431
464
|
// action's rule in the event line itself (`listen.ts`), the mantra in the reply's own `reminders[]` text.
|
|
432
|
-
|
|
465
|
+
// DX-4390 — signed out, this is also how the session asks for access.
|
|
466
|
+
"Connect THIS session to a plan; one call is enough to start. Signed out, it requests access instead: " +
|
|
467
|
+
"the reply is `state: approval_required` or `approval_pending` with an `approvalUrl` and `confirmCode` " +
|
|
468
|
+
"(show the code, open the URL, then call again). Signed in, the reply carries `missedEvents` (operator " +
|
|
433
469
|
"events on the plan that no session had been told about, oldest first: read them first), " +
|
|
434
470
|
"`browserInstruction` (do it now), `briefing` (the plan's goals, rules, caveats, architecture section " +
|
|
435
471
|
"titles and a first page of open cards) and `listenerHealth` (when its `state` is not `healthy`, do its " +
|
|
436
472
|
"`nextStep`). A session is on at most one plan: connecting elsewhere moves it, and `movedFrom` names the " +
|
|
437
473
|
"plan it left. Afterwards a `danxbot_api` call to a `/api/plans/mine/...` route acts on this plan and " +
|
|
438
474
|
"takes no plan id.", {
|
|
439
|
-
plan_id: z
|
|
475
|
+
plan_id: z
|
|
476
|
+
.number()
|
|
477
|
+
.int()
|
|
478
|
+
.positive()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`). Omit only to sign in."),
|
|
440
481
|
title: z
|
|
441
482
|
.string()
|
|
442
483
|
.min(1)
|
|
@@ -449,7 +490,29 @@ strictTool("plan_connect",
|
|
|
449
490
|
// session may have configured, the one that connected the plan is the one
|
|
450
491
|
// whose credential the stream must be minted with.
|
|
451
492
|
async (args) => {
|
|
452
|
-
|
|
493
|
+
// DX-4390 — a leave names the plan it leaves, and needs a key like any other call.
|
|
494
|
+
if (args.disconnect === true && args.plan_id === undefined) {
|
|
495
|
+
return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
|
|
496
|
+
}
|
|
497
|
+
// DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
|
|
498
|
+
const ready = await access.ensureAccess(args.title);
|
|
499
|
+
if (!ready.signedIn)
|
|
500
|
+
return accessReply(ready);
|
|
501
|
+
if (args.plan_id === undefined) {
|
|
502
|
+
return jsonResult({
|
|
503
|
+
state: "signed_in",
|
|
504
|
+
instruction: "This session is signed in. Call `plan_connect` with `plan_id` to connect it to a plan.",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
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);
|
|
512
|
+
// DX-4390 — the dashboard no longer accepts the stored key: drop it and ask again in the same call.
|
|
513
|
+
if (result.status === 401 && access.dropKey()) {
|
|
514
|
+
return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
|
|
515
|
+
}
|
|
453
516
|
// DX-3997 — a (re)connect restarts the bridge; the next `danxbot_api` must re-read health.
|
|
454
517
|
listenerReminder.invalidate();
|
|
455
518
|
// DX-2985 — a leave drops the local connection record (the bridge exits on `not_connected`; a later connect writes a fresh one).
|
|
@@ -457,6 +520,9 @@ async (args) => {
|
|
|
457
520
|
removeSessionConnectionAfterLeave(result, config.session?.id);
|
|
458
521
|
return jsonResult(result);
|
|
459
522
|
}
|
|
523
|
+
const connection = access.connection();
|
|
524
|
+
if (connection === null)
|
|
525
|
+
throw new Error("plan_connect: no credential after a successful connect");
|
|
460
526
|
return jsonResult({
|
|
461
527
|
...result,
|
|
462
528
|
...recordSessionConnectionAfterConnect(result, {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
* 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
|
+
*
|
|
16
|
+
* ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `plan_connect` posts the request and
|
|
17
|
+
* answers at once with the approval URL and confirm code. From then on THIS object polls claim in
|
|
18
|
+
* the background, so the key is stored the moment the user approves, and the next `plan_connect`
|
|
19
|
+
* waits at most `awaitMs` (about 45s) for it before answering `approval_pending`. The secret the
|
|
20
|
+
* poll needs lives only in this process's memory; a restarted session asks again.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { hostname } from "node:os";
|
|
24
|
+
import { claimAccessRequest, createAccessRequest } from "./access-request.js";
|
|
25
|
+
import { credentialFingerprint } from "./credential.js";
|
|
26
|
+
import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
|
|
27
|
+
import { deleteSessionKey, writeSessionKey } from "./session-key.js";
|
|
28
|
+
import { McpOutdatedError } from "./version-floor.js";
|
|
29
|
+
/** Longest a `plan_connect` call waits for an approval already asked for. */
|
|
30
|
+
export const AWAIT_APPROVAL_MS = 45_000;
|
|
31
|
+
/** The server reaps a lapsed request about a minute after expiry; polling past that finds nothing. */
|
|
32
|
+
const POLL_GRACE_MS = 90_000;
|
|
33
|
+
/** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
|
|
34
|
+
const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
|
|
35
|
+
/** The request's title when the agent gave none: the session id's first characters. */
|
|
36
|
+
const TITLE_FALLBACK_LENGTH = 8;
|
|
37
|
+
export const SIGN_IN_HALT = "Not signed in to the danxbot dashboard. Call `plan_connect` (with `title`: your session's own " +
|
|
38
|
+
"title) to request access; the user approves it in their browser, then this tool works.";
|
|
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. " +
|
|
41
|
+
SIGN_IN_HALT;
|
|
42
|
+
const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
|
|
43
|
+
"matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval.";
|
|
44
|
+
const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve. Remind them of the confirm code and the URL, then call " +
|
|
45
|
+
"`plan_connect` again.";
|
|
46
|
+
const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
|
|
47
|
+
const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
|
|
48
|
+
"it must be updated before this session can sign in";
|
|
49
|
+
const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
|
|
50
|
+
const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
|
|
51
|
+
const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
|
|
52
|
+
"start a new request";
|
|
53
|
+
const EXPIRED_NOTE = "The previous request expired before it was approved.";
|
|
54
|
+
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
55
|
+
setTimeout(resolve, ms).unref();
|
|
56
|
+
});
|
|
57
|
+
const notReady = (answer, isError, halt) => ({
|
|
58
|
+
signedIn: false,
|
|
59
|
+
answer,
|
|
60
|
+
isError,
|
|
61
|
+
...(halt === undefined ? {} : { halt }),
|
|
62
|
+
});
|
|
63
|
+
export class SessionAccess {
|
|
64
|
+
deps;
|
|
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;
|
|
68
|
+
pending = null;
|
|
69
|
+
// DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
|
|
70
|
+
ensureQueue = Promise.resolve();
|
|
71
|
+
lastKeyFileWrite = 0;
|
|
72
|
+
fetchImpl;
|
|
73
|
+
now;
|
|
74
|
+
sleep;
|
|
75
|
+
awaitMs;
|
|
76
|
+
constructor(deps) {
|
|
77
|
+
this.deps = deps;
|
|
78
|
+
this.credential = deps.initial;
|
|
79
|
+
this.fetchImpl = deps.fetch ?? fetch;
|
|
80
|
+
this.now = deps.now ?? Date.now;
|
|
81
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
82
|
+
this.awaitMs = deps.awaitMs ?? AWAIT_APPROVAL_MS;
|
|
83
|
+
deps.setToken(deps.initial?.token ?? "");
|
|
84
|
+
}
|
|
85
|
+
isSignedIn() {
|
|
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();
|
|
103
|
+
}
|
|
104
|
+
/** Where the current credential comes from and its fingerprint, for the bridge's connection record. */
|
|
105
|
+
connection() {
|
|
106
|
+
if (this.credential === null || this.revoked !== null)
|
|
107
|
+
return null;
|
|
108
|
+
return {
|
|
109
|
+
dashboardUrl: this.deps.dashboardUrl.replace(/\/+$/, ""),
|
|
110
|
+
source: this.credential.source,
|
|
111
|
+
fingerprint: this.credential.fingerprint,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The dashboard answered 401 to this session's key: drop it. A dispatch credential is never
|
|
116
|
+
* dropped (the dispatch owns it). True when this signed the session out.
|
|
117
|
+
*/
|
|
118
|
+
dropKey() {
|
|
119
|
+
if (this.credential === null || this.credential.source.kind !== "session-key")
|
|
120
|
+
return false;
|
|
121
|
+
deleteSessionKey(this.credential.source.path);
|
|
122
|
+
this.credential = null;
|
|
123
|
+
this.pending = null; // DX-4390: a settled request of the dropped key must not read as "expired"
|
|
124
|
+
this.deps.setToken("");
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Run a dashboard call for a tool: halted while signed out, and signed out (key file deleted)
|
|
129
|
+
* when the dashboard answers 401 to this session's key.
|
|
130
|
+
*/
|
|
131
|
+
async guard(run) {
|
|
132
|
+
const revokedHalt = this.revokedHalt();
|
|
133
|
+
if (revokedHalt !== null)
|
|
134
|
+
return { halt: revokedHalt };
|
|
135
|
+
if (this.credential === null)
|
|
136
|
+
return { halt: SIGN_IN_HALT };
|
|
137
|
+
const result = await run();
|
|
138
|
+
const refused = this.refusalHalt(result);
|
|
139
|
+
if (refused !== null)
|
|
140
|
+
return { halt: refused };
|
|
141
|
+
if (result.status === 401 && this.dropKey())
|
|
142
|
+
return { halt: KEY_LAPSED_HALT };
|
|
143
|
+
this.keepKeyFile();
|
|
144
|
+
return { result };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* DX-4390: the stored file is the bridge's only way to the key, and the 7-day prune ages out
|
|
148
|
+
* a file by when it was last written. A server in use rewrites it (atomically) at most hourly,
|
|
149
|
+
* which also restores a file someone deleted while this process still holds the key.
|
|
150
|
+
*/
|
|
151
|
+
keepKeyFile() {
|
|
152
|
+
const credential = this.credential;
|
|
153
|
+
if (credential === null || credential.source.kind !== "session-key")
|
|
154
|
+
return;
|
|
155
|
+
const now = this.now();
|
|
156
|
+
if (now - this.lastKeyFileWrite < KEEP_KEY_FILE_EVERY_MS && existsSync(credential.source.path))
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey: credential.token }, { home: this.deps.home, now });
|
|
160
|
+
this.lastKeyFileWrite = now;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
this.deps.log?.(`[danx-dashboard-mcp] could not refresh the session key file: ${err.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Make sure this session holds a key. Signed in: `{signedIn: true}`. Otherwise the answer
|
|
168
|
+
* `plan_connect` returns: the request just made, the one still pending (after waiting up to
|
|
169
|
+
* `awaitMs` for it), or why there is none. Calls run one at a time.
|
|
170
|
+
*/
|
|
171
|
+
ensureAccess(title) {
|
|
172
|
+
const run = this.ensureQueue.then(() => this.ensureAccessNow(title));
|
|
173
|
+
this.ensureQueue = run.catch(() => undefined);
|
|
174
|
+
return run;
|
|
175
|
+
}
|
|
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);
|
|
181
|
+
if (this.credential !== null)
|
|
182
|
+
return { signedIn: true };
|
|
183
|
+
const sessionId = this.deps.sessionId;
|
|
184
|
+
if (sessionId === undefined || sessionId === "") {
|
|
185
|
+
return notReady({ state: "no_session_id", error: NO_SESSION_ID_ERROR }, true);
|
|
186
|
+
}
|
|
187
|
+
let note;
|
|
188
|
+
if (this.pending !== null) {
|
|
189
|
+
const pending = this.pending;
|
|
190
|
+
const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
|
|
191
|
+
if (outcome === null)
|
|
192
|
+
return notReady(this.requestAnswer("approval_pending", pending.request), false);
|
|
193
|
+
this.pending = null;
|
|
194
|
+
switch (outcome.kind) {
|
|
195
|
+
case "approved":
|
|
196
|
+
return { signedIn: true };
|
|
197
|
+
case "denied":
|
|
198
|
+
return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
|
|
199
|
+
case "failed":
|
|
200
|
+
return notReady({ state: "request_failed", error: outcome.error }, true);
|
|
201
|
+
case "expired":
|
|
202
|
+
note = EXPIRED_NOTE;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return this.createRequest(sessionId, title, note);
|
|
206
|
+
}
|
|
207
|
+
async createRequest(sessionId, title, note) {
|
|
208
|
+
const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
|
|
209
|
+
kind: "session",
|
|
210
|
+
labelId: sessionId,
|
|
211
|
+
title: title ?? sessionId.slice(0, TITLE_FALLBACK_LENGTH),
|
|
212
|
+
repo: this.deps.repo,
|
|
213
|
+
machine: this.deps.machine ?? hostname(),
|
|
214
|
+
clientVersion: this.deps.clientVersion,
|
|
215
|
+
});
|
|
216
|
+
switch (created.kind) {
|
|
217
|
+
case "dashboard_outdated":
|
|
218
|
+
return notReady({ state: "dashboard_outdated", error: DASHBOARD_OUTDATED_ERROR }, true);
|
|
219
|
+
case "rate_limited":
|
|
220
|
+
return notReady({ state: "rate_limited", retryAfterSeconds: created.retryAfterSeconds, error: RATE_LIMITED_ERROR }, true);
|
|
221
|
+
case "refused":
|
|
222
|
+
return notReady({ state: "request_refused", status: created.status, body: created.body }, true);
|
|
223
|
+
case "created":
|
|
224
|
+
this.pending = { request: created.request, settled: this.pollUntilSettled(created.request) };
|
|
225
|
+
return notReady({ ...this.requestAnswer("approval_required", created.request), ...(note ? { note } : {}) }, false);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
requestAnswer(state, request) {
|
|
229
|
+
return {
|
|
230
|
+
state,
|
|
231
|
+
approvalUrl: request.approvalUrl,
|
|
232
|
+
confirmCode: request.confirmCode,
|
|
233
|
+
expiresAt: request.expiresAt,
|
|
234
|
+
instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async pollUntilSettled(request) {
|
|
238
|
+
const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
|
|
239
|
+
let waitMs = request.pollMs;
|
|
240
|
+
for (;;) {
|
|
241
|
+
await this.sleep(waitMs);
|
|
242
|
+
waitMs = request.pollMs;
|
|
243
|
+
if (this.now() > giveUpAt)
|
|
244
|
+
return { kind: "expired" };
|
|
245
|
+
let claim;
|
|
246
|
+
try {
|
|
247
|
+
claim = await claimAccessRequest(this.fetchImpl, this.deps.dashboardUrl, request.secret);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
// A claim the dashboard could not answer is retried until the request lapses; an outdated
|
|
251
|
+
// MCP will never get a different answer, so that one ends it.
|
|
252
|
+
if (err instanceof McpOutdatedError)
|
|
253
|
+
return { kind: "failed", error: err.message };
|
|
254
|
+
this.deps.log?.(`[danx-dashboard-mcp] access request poll failed, retrying: ${err.message}`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
switch (claim.kind) {
|
|
258
|
+
case "pending":
|
|
259
|
+
continue;
|
|
260
|
+
case "rate_limited":
|
|
261
|
+
waitMs = Math.max(request.pollMs, claim.retryAfterSeconds * 1000);
|
|
262
|
+
continue;
|
|
263
|
+
case "approved":
|
|
264
|
+
return this.storeKey(claim.apiKey);
|
|
265
|
+
case "denied":
|
|
266
|
+
return { kind: "denied" };
|
|
267
|
+
case "expired":
|
|
268
|
+
case "not_found":
|
|
269
|
+
return { kind: "expired" };
|
|
270
|
+
case "claimed":
|
|
271
|
+
return { kind: "failed", error: CLAIMED_LOST_ERROR };
|
|
272
|
+
case "refused":
|
|
273
|
+
return {
|
|
274
|
+
kind: "failed",
|
|
275
|
+
error: `the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
storeKey(apiKey) {
|
|
281
|
+
const now = this.now();
|
|
282
|
+
try {
|
|
283
|
+
const path = writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey }, { home: this.deps.home, now });
|
|
284
|
+
this.credential = {
|
|
285
|
+
source: { kind: "session-key", path },
|
|
286
|
+
token: apiKey,
|
|
287
|
+
fingerprint: credentialFingerprint(apiKey),
|
|
288
|
+
};
|
|
289
|
+
this.lastKeyFileWrite = now;
|
|
290
|
+
this.deps.setToken(apiKey);
|
|
291
|
+
return { kind: "approved" };
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
kind: "failed",
|
|
296
|
+
error: `the request was approved but storing the key failed: ${err.message}; start a new request`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -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 =
|
|
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
|
-
/**
|
|
58
|
-
|
|
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
|
|
65
|
-
export function
|
|
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
|
-
|
|
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 === "
|
|
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.
|
|
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",
|