@thehammer/danx-dashboard-mcp 0.1.222 → 0.1.224
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/access-request.js +90 -0
- package/dist/bridge.js +4 -4
- package/dist/credential.js +60 -80
- package/dist/http-client.js +11 -0
- package/dist/index.js +97 -39
- package/dist/session-access.js +265 -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). **A 404 on create** means the dashboard predates request-and-approve and says so.
|
|
24
|
+
- A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
|
|
25
|
+
|
|
18
26
|
## Tool surface (DX-3465 — one generic tool, not one per operation)
|
|
19
27
|
|
|
20
28
|
All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API:
|
|
@@ -39,7 +47,7 @@ A program runs this, not an agent: the danxbot Claude Code plugin's plan event b
|
|
|
39
47
|
CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
|
|
40
48
|
```
|
|
41
49
|
|
|
42
|
-
- **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANX_DASHBOARD_CREDENTIAL` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
|
|
50
|
+
- **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE (a session key file, or the env var name), and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANX_DASHBOARD_CREDENTIAL` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
|
|
43
51
|
- **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED. The dashboard briefly enforced the identical requirement server-side (DX-2863: refusing a mint or stream admission with `403 issuer_cannot_read_plan_boards`, and ending a live stream `{"reason":"scope_narrowed","boards":[…]}`), but DX-3468 removed that mechanism too — DX-3409 made every credential team-bound, so a plan session's issuer and every board its connected plan covers are always in the same team, and the check could never fire any more. `bridge` no longer independently verifies board reach at all, client- or server-side. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
|
|
44
52
|
- **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
|
|
45
53
|
- **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
|
|
2
|
+
function baseOf(dashboardUrl) {
|
|
3
|
+
return dashboardUrl.replace(/\/+$/, "");
|
|
4
|
+
}
|
|
5
|
+
async function postPublic(fetchImpl, url, body) {
|
|
6
|
+
let res;
|
|
7
|
+
try {
|
|
8
|
+
res = await fetchImpl(url, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
11
|
+
body: JSON.stringify(body),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw new Error(`[danx-dashboard-mcp] network failure on POST ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
16
|
+
}
|
|
17
|
+
assertVersionFloorSatisfied(res.headers.get(MCP_MIN_VERSION_HEADER));
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
if (res.status >= 500) {
|
|
20
|
+
throw new Error(`[danx-dashboard-mcp] server error ${res.status} on POST ${url}: ${text.slice(0, 500)}`);
|
|
21
|
+
}
|
|
22
|
+
let json = null;
|
|
23
|
+
if (text !== "") {
|
|
24
|
+
try {
|
|
25
|
+
json = JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(`[danx-dashboard-mcp] non-JSON response (status ${res.status}) from POST ${url}: ${text.slice(0, 200)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { status: res.status, headers: res.headers, json };
|
|
32
|
+
}
|
|
33
|
+
/** When a 429 carries no usable `Retry-After`, wait this long before the next try. */
|
|
34
|
+
const DEFAULT_RETRY_AFTER_SECONDS = 1;
|
|
35
|
+
function retryAfterSeconds(headers) {
|
|
36
|
+
const seconds = Number(headers.get("retry-after"));
|
|
37
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_RETRY_AFTER_SECONDS;
|
|
38
|
+
}
|
|
39
|
+
function isCreated(value) {
|
|
40
|
+
if (typeof value !== "object" || value === null)
|
|
41
|
+
return false;
|
|
42
|
+
const v = value;
|
|
43
|
+
return (typeof v.publicId === "string" &&
|
|
44
|
+
typeof v.secret === "string" &&
|
|
45
|
+
typeof v.confirmCode === "string" &&
|
|
46
|
+
typeof v.approvalUrl === "string" &&
|
|
47
|
+
typeof v.expiresAt === "string" &&
|
|
48
|
+
Number.isFinite(Date.parse(v.expiresAt)) &&
|
|
49
|
+
typeof v.pollMs === "number");
|
|
50
|
+
}
|
|
51
|
+
export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
|
|
52
|
+
const url = `${baseOf(dashboardUrl)}/api/access-requests`;
|
|
53
|
+
const { status, headers, json } = await postPublic(fetchImpl, url, input);
|
|
54
|
+
if (status === 201) {
|
|
55
|
+
if (!isCreated(json)) {
|
|
56
|
+
throw new Error(`[danx-dashboard-mcp] POST ${url} answered 201 without the access request fields: ${JSON.stringify(json)}`);
|
|
57
|
+
}
|
|
58
|
+
return { kind: "created", request: json };
|
|
59
|
+
}
|
|
60
|
+
if (status === 404)
|
|
61
|
+
return { kind: "dashboard_outdated" };
|
|
62
|
+
if (status === 429)
|
|
63
|
+
return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
|
|
64
|
+
return { kind: "refused", status, body: json };
|
|
65
|
+
}
|
|
66
|
+
export async function claimAccessRequest(fetchImpl, dashboardUrl, secret) {
|
|
67
|
+
const url = `${baseOf(dashboardUrl)}/api/access-requests/claim`;
|
|
68
|
+
const { status, headers, json } = await postPublic(fetchImpl, url, { secret });
|
|
69
|
+
if (status === 404)
|
|
70
|
+
return { kind: "not_found" };
|
|
71
|
+
if (status === 429)
|
|
72
|
+
return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
|
|
73
|
+
if (status !== 200)
|
|
74
|
+
return { kind: "refused", status, body: json };
|
|
75
|
+
const body = json;
|
|
76
|
+
switch (body?.status) {
|
|
77
|
+
case "pending":
|
|
78
|
+
case "denied":
|
|
79
|
+
case "expired":
|
|
80
|
+
case "claimed":
|
|
81
|
+
return { kind: body.status };
|
|
82
|
+
case "approved":
|
|
83
|
+
if (typeof body.apiKey !== "string" || body.apiKey === "") {
|
|
84
|
+
throw new Error(`[danx-dashboard-mcp] POST ${url} answered approved without an apiKey`);
|
|
85
|
+
}
|
|
86
|
+
return { kind: "approved", apiKey: body.apiKey };
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`[danx-dashboard-mcp] POST ${url} answered an unknown status: ${JSON.stringify(json)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/bridge.js
CHANGED
|
@@ -216,11 +216,11 @@ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${B
|
|
|
216
216
|
export const STOP_FIXES = {
|
|
217
217
|
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
|
|
218
218
|
"(a server older than the one that introduced the record writes none)",
|
|
219
|
-
credential_unavailable: "
|
|
219
|
+
credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
|
|
220
220
|
credential_mismatch: "restart this Claude Code session so its MCP server and its bridge pick up the same credential, then call plan_connect again",
|
|
221
221
|
not_connected: "call plan_connect to connect this session to a plan",
|
|
222
222
|
plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
|
|
223
|
-
unauthorized: "
|
|
223
|
+
unauthorized: "the dashboard no longer accepts this session's key (revoked, or lapsed after a day unused): call plan_connect again to request access",
|
|
224
224
|
mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
|
|
225
225
|
mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
|
|
226
226
|
// A newer ticket exists (superseded) or another connection is already using
|
|
@@ -300,13 +300,13 @@ function resolveInstanceId(env) {
|
|
|
300
300
|
}
|
|
301
301
|
/**
|
|
302
302
|
* DX-3028 — the on-disk paths this bridge's authentication depends on, for
|
|
303
|
-
* `BridgeOptions.watchedPaths` (see its docblock). A `
|
|
303
|
+
* `BridgeOptions.watchedPaths` (see its docblock). A `session-key`
|
|
304
304
|
* credential adds its own path; an `env`-sourced one has no file
|
|
305
305
|
* (`credential.ts` ~47-48, ~90-95).
|
|
306
306
|
*/
|
|
307
307
|
function watchedPathsFor(record, home) {
|
|
308
308
|
const paths = [sessionConnectionPath(record.sessionId, home)];
|
|
309
|
-
if (record.source.kind === "
|
|
309
|
+
if (record.source.kind === "session-key")
|
|
310
310
|
paths.push(record.source.path);
|
|
311
311
|
return paths;
|
|
312
312
|
}
|
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,21 @@ export const server = new McpServer({
|
|
|
261
272
|
function haltResult(text) {
|
|
262
273
|
return { content: [{ type: "text", text }], isError: true };
|
|
263
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* DX-4390 — `plan_connect`'s answer while access is not in place: the request (or why there is none),
|
|
277
|
+
* an error when it names a failure. `note` says why a new request was needed.
|
|
278
|
+
*/
|
|
279
|
+
function accessReply(ready, note) {
|
|
280
|
+
if (ready.signedIn)
|
|
281
|
+
return jsonResult({ state: "signed_in", ...(note ? { note } : {}) });
|
|
282
|
+
const answer = note ? { ...ready.answer, note } : ready.answer;
|
|
283
|
+
return ready.isError ? haltResult(JSON.stringify(answer, null, 2)) : jsonResult(answer);
|
|
284
|
+
}
|
|
285
|
+
/** DX-4390 — a tool's dashboard call behind the sign-in halt (and the 401 sign-out). */
|
|
286
|
+
async function guardedJson(run) {
|
|
287
|
+
const guarded = await access.guard(run);
|
|
288
|
+
return "halt" in guarded ? haltResult(guarded.halt) : jsonResult(guarded.result);
|
|
289
|
+
}
|
|
264
290
|
function jsonResult(value) {
|
|
265
291
|
return {
|
|
266
292
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -371,7 +397,10 @@ async (args) => {
|
|
|
371
397
|
const refused = termsAcceptanceCallHalt(args, config.baseUrl);
|
|
372
398
|
if (refused)
|
|
373
399
|
return haltResult(refused);
|
|
374
|
-
const
|
|
400
|
+
const guarded = await access.guard(() => danxbotApi(client, args));
|
|
401
|
+
if ("halt" in guarded)
|
|
402
|
+
return haltResult(guarded.halt);
|
|
403
|
+
const result = guarded.result;
|
|
375
404
|
const halt = termsHaltFor(result);
|
|
376
405
|
if (halt)
|
|
377
406
|
return haltResult(halt);
|
|
@@ -382,7 +411,7 @@ strictTool("danxbot_api_list",
|
|
|
382
411
|
// DX-3528 — the list is filtered by the same permission check every request takes.
|
|
383
412
|
"Lists the dashboard API routes your credential may call: `[{method, path, summary, permission}]`, each " +
|
|
384
413
|
"`path` a route template (`/api/issues/:id/transition`), the form `danxbot_api_spec` takes. A route not " +
|
|
385
|
-
"listed is not callable by you.", {}, async () =>
|
|
414
|
+
"listed is not callable by you.", {}, async () => guardedJson(() => danxbotApiList(client)));
|
|
386
415
|
// ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
|
|
387
416
|
strictTool("danxbot_api_spec",
|
|
388
417
|
// DX-3527 — the contract is generated from the route table.
|
|
@@ -391,7 +420,7 @@ strictTool("danxbot_api_spec",
|
|
|
391
420
|
'(e.g. `"POST /api/issues/:id/transition"`), never a real path with ids. An endpoint the dashboard does ' +
|
|
392
421
|
"not serve, or your credential may not call, is refused and named.", {
|
|
393
422
|
endpoints: z.array(z.string().min(1)).min(1),
|
|
394
|
-
}, async (args) =>
|
|
423
|
+
}, async (args) => guardedJson(() => danxbotApiSpec(client, args)));
|
|
395
424
|
// ---------------- attach_file (DX-3465 A4, checklist item 36140) ----------------
|
|
396
425
|
//
|
|
397
426
|
// The "25 MB" ceiling quoted below tracks the backend route's
|
|
@@ -416,7 +445,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
416
445
|
filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
|
|
417
446
|
content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
|
|
418
447
|
...boardField,
|
|
419
|
-
}, async (args) =>
|
|
448
|
+
}, async (args) => guardedJson(() => attachFile(client, args)));
|
|
420
449
|
// ---------------- plans (DX-2683) ----------------
|
|
421
450
|
// THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
|
|
422
451
|
// takes a plan id in its path; the plan records, plan cards and
|
|
@@ -430,14 +459,22 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
430
459
|
strictTool("plan_connect",
|
|
431
460
|
// DX-4318 — states only what to do with the reply. What it used to add arrives where it is used: a rejected
|
|
432
461
|
// action's rule in the event line itself (`listen.ts`), the mantra in the reply's own `reminders[]` text.
|
|
433
|
-
|
|
462
|
+
// DX-4390 — signed out, this is also how the session asks for access.
|
|
463
|
+
"Connect THIS session to a plan; one call is enough to start. Signed out, it requests access instead: " +
|
|
464
|
+
"the reply is `state: approval_required` or `approval_pending` with an `approvalUrl` and `confirmCode` " +
|
|
465
|
+
"(show the code, open the URL, then call again). Signed in, the reply carries `missedEvents` (operator " +
|
|
434
466
|
"events on the plan that no session had been told about, oldest first: read them first), " +
|
|
435
467
|
"`browserInstruction` (do it now), `briefing` (the plan's goals, rules, caveats, architecture section " +
|
|
436
468
|
"titles and a first page of open cards) and `listenerHealth` (when its `state` is not `healthy`, do its " +
|
|
437
469
|
"`nextStep`). A session is on at most one plan: connecting elsewhere moves it, and `movedFrom` names the " +
|
|
438
470
|
"plan it left. Afterwards a `danxbot_api` call to a `/api/plans/mine/...` route acts on this plan and " +
|
|
439
471
|
"takes no plan id.", {
|
|
440
|
-
plan_id: z
|
|
472
|
+
plan_id: z
|
|
473
|
+
.number()
|
|
474
|
+
.int()
|
|
475
|
+
.positive()
|
|
476
|
+
.optional()
|
|
477
|
+
.describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`). Omit only to sign in."),
|
|
441
478
|
title: z
|
|
442
479
|
.string()
|
|
443
480
|
.min(1)
|
|
@@ -450,7 +487,25 @@ strictTool("plan_connect",
|
|
|
450
487
|
// session may have configured, the one that connected the plan is the one
|
|
451
488
|
// whose credential the stream must be minted with.
|
|
452
489
|
async (args) => {
|
|
453
|
-
|
|
490
|
+
// DX-4390 — a leave names the plan it leaves, and needs a key like any other call.
|
|
491
|
+
if (args.disconnect === true && args.plan_id === undefined) {
|
|
492
|
+
return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
|
|
493
|
+
}
|
|
494
|
+
// DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
|
|
495
|
+
const ready = await access.ensureAccess(args.title);
|
|
496
|
+
if (!ready.signedIn)
|
|
497
|
+
return accessReply(ready);
|
|
498
|
+
if (args.plan_id === undefined) {
|
|
499
|
+
return jsonResult({
|
|
500
|
+
state: "signed_in",
|
|
501
|
+
instruction: "This session is signed in. Call `plan_connect` with `plan_id` to connect it to a plan.",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const result = await planConnect(client, { ...args, plan_id: args.plan_id });
|
|
505
|
+
// DX-4390 — the dashboard no longer accepts the stored key: drop it and ask again in the same call.
|
|
506
|
+
if (result.status === 401 && access.dropKey()) {
|
|
507
|
+
return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
|
|
508
|
+
}
|
|
454
509
|
// DX-3997 — a (re)connect restarts the bridge; the next `danxbot_api` must re-read health.
|
|
455
510
|
listenerReminder.invalidate();
|
|
456
511
|
// DX-2985 — a leave drops the local connection record (the bridge exits on `not_connected`; a later connect writes a fresh one).
|
|
@@ -458,6 +513,9 @@ async (args) => {
|
|
|
458
513
|
removeSessionConnectionAfterLeave(result, config.session?.id);
|
|
459
514
|
return jsonResult(result);
|
|
460
515
|
}
|
|
516
|
+
const connection = access.connection();
|
|
517
|
+
if (connection === null)
|
|
518
|
+
throw new Error("plan_connect: no credential after a successful connect");
|
|
461
519
|
return jsonResult({
|
|
462
520
|
...result,
|
|
463
521
|
...recordSessionConnectionAfterConnect(result, {
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4390 (PLAN-17 section 96) - how this server gets, holds and loses its dashboard credential.
|
|
3
|
+
*
|
|
4
|
+
* Three states, none of them a process exit:
|
|
5
|
+
*
|
|
6
|
+
* - `env` a dispatch declared `DANX_DASHBOARD_CREDENTIAL`; this never signs out.
|
|
7
|
+
* - `session-key` a user approved this session's access request; the key is stored per session
|
|
8
|
+
* (`session-key.ts`) and a 401 on it deletes the file (`dropKey`).
|
|
9
|
+
* - signed out no credential. Every tool but `plan_connect` halts; `plan_connect` asks.
|
|
10
|
+
*
|
|
11
|
+
* ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `plan_connect` posts the request and
|
|
12
|
+
* answers at once with the approval URL and confirm code. From then on THIS object polls claim in
|
|
13
|
+
* the background, so the key is stored the moment the user approves, and the next `plan_connect`
|
|
14
|
+
* waits at most `awaitMs` (about 45s) for it before answering `approval_pending`. The secret the
|
|
15
|
+
* poll needs lives only in this process's memory; a restarted session asks again.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { hostname } from "node:os";
|
|
19
|
+
import { claimAccessRequest, createAccessRequest } from "./access-request.js";
|
|
20
|
+
import { credentialFingerprint } from "./credential.js";
|
|
21
|
+
import { deleteSessionKey, writeSessionKey } from "./session-key.js";
|
|
22
|
+
import { McpOutdatedError } from "./version-floor.js";
|
|
23
|
+
/** Longest a `plan_connect` call waits for an approval already asked for. */
|
|
24
|
+
export const AWAIT_APPROVAL_MS = 45_000;
|
|
25
|
+
/** The server reaps a lapsed request about a minute after expiry; polling past that finds nothing. */
|
|
26
|
+
const POLL_GRACE_MS = 90_000;
|
|
27
|
+
/** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
|
|
28
|
+
const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
|
|
29
|
+
/** The request's title when the agent gave none: the session id's first characters. */
|
|
30
|
+
const TITLE_FALLBACK_LENGTH = 8;
|
|
31
|
+
export const SIGN_IN_HALT = "Not signed in to the danxbot dashboard. Call `plan_connect` (with `title`: your session's own " +
|
|
32
|
+
"title) to request access; the user approves it in their browser, then this tool works.";
|
|
33
|
+
export const REVOKED_HALT = "The dashboard no longer accepts this session's key (it was revoked, or it lapsed after a day " +
|
|
34
|
+
"unused), so this session is signed out. " +
|
|
35
|
+
SIGN_IN_HALT;
|
|
36
|
+
const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
|
|
37
|
+
"matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval.";
|
|
38
|
+
const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve. Remind them of the confirm code and the URL, then call " +
|
|
39
|
+
"`plan_connect` again.";
|
|
40
|
+
const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
|
|
41
|
+
const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
|
|
42
|
+
"it must be updated before this session can sign in";
|
|
43
|
+
const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
|
|
44
|
+
const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
|
|
45
|
+
const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
|
|
46
|
+
"start a new request";
|
|
47
|
+
const EXPIRED_NOTE = "The previous request expired before it was approved.";
|
|
48
|
+
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
49
|
+
setTimeout(resolve, ms).unref();
|
|
50
|
+
});
|
|
51
|
+
const notReady = (answer, isError) => ({
|
|
52
|
+
signedIn: false,
|
|
53
|
+
answer,
|
|
54
|
+
isError,
|
|
55
|
+
});
|
|
56
|
+
export class SessionAccess {
|
|
57
|
+
deps;
|
|
58
|
+
credential;
|
|
59
|
+
pending = null;
|
|
60
|
+
// DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
|
|
61
|
+
ensureQueue = Promise.resolve();
|
|
62
|
+
lastKeyFileWrite = 0;
|
|
63
|
+
fetchImpl;
|
|
64
|
+
now;
|
|
65
|
+
sleep;
|
|
66
|
+
awaitMs;
|
|
67
|
+
constructor(deps) {
|
|
68
|
+
this.deps = deps;
|
|
69
|
+
this.credential = deps.initial;
|
|
70
|
+
this.fetchImpl = deps.fetch ?? fetch;
|
|
71
|
+
this.now = deps.now ?? Date.now;
|
|
72
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
73
|
+
this.awaitMs = deps.awaitMs ?? AWAIT_APPROVAL_MS;
|
|
74
|
+
deps.setToken(deps.initial?.token ?? "");
|
|
75
|
+
}
|
|
76
|
+
isSignedIn() {
|
|
77
|
+
return this.credential !== null;
|
|
78
|
+
}
|
|
79
|
+
/** Where the current credential comes from and its fingerprint, for the bridge's connection record. */
|
|
80
|
+
connection() {
|
|
81
|
+
if (this.credential === null)
|
|
82
|
+
return null;
|
|
83
|
+
return {
|
|
84
|
+
dashboardUrl: this.deps.dashboardUrl.replace(/\/+$/, ""),
|
|
85
|
+
source: this.credential.source,
|
|
86
|
+
fingerprint: this.credential.fingerprint,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The dashboard answered 401 to this session's key: drop it. A dispatch credential is never
|
|
91
|
+
* dropped (the dispatch owns it). True when this signed the session out.
|
|
92
|
+
*/
|
|
93
|
+
dropKey() {
|
|
94
|
+
if (this.credential === null || this.credential.source.kind !== "session-key")
|
|
95
|
+
return false;
|
|
96
|
+
deleteSessionKey(this.credential.source.path);
|
|
97
|
+
this.credential = null;
|
|
98
|
+
this.pending = null; // DX-4390: a settled request of the dropped key must not read as "expired"
|
|
99
|
+
this.deps.setToken("");
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run a dashboard call for a tool: halted while signed out, and signed out (key file deleted)
|
|
104
|
+
* when the dashboard answers 401 to this session's key.
|
|
105
|
+
*/
|
|
106
|
+
async guard(run) {
|
|
107
|
+
if (this.credential === null)
|
|
108
|
+
return { halt: SIGN_IN_HALT };
|
|
109
|
+
const result = await run();
|
|
110
|
+
if (result.status === 401 && this.dropKey())
|
|
111
|
+
return { halt: REVOKED_HALT };
|
|
112
|
+
this.keepKeyFile();
|
|
113
|
+
return { result };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* DX-4390: the stored file is the bridge's only way to the key, and the 7-day prune ages out
|
|
117
|
+
* a file by when it was last written. A server in use rewrites it (atomically) at most hourly,
|
|
118
|
+
* which also restores a file someone deleted while this process still holds the key.
|
|
119
|
+
*/
|
|
120
|
+
keepKeyFile() {
|
|
121
|
+
const credential = this.credential;
|
|
122
|
+
if (credential === null || credential.source.kind !== "session-key")
|
|
123
|
+
return;
|
|
124
|
+
const now = this.now();
|
|
125
|
+
if (now - this.lastKeyFileWrite < KEEP_KEY_FILE_EVERY_MS && existsSync(credential.source.path))
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey: credential.token }, { home: this.deps.home, now });
|
|
129
|
+
this.lastKeyFileWrite = now;
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
this.deps.log?.(`[danx-dashboard-mcp] could not refresh the session key file: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Make sure this session holds a key. Signed in: `{signedIn: true}`. Otherwise the answer
|
|
137
|
+
* `plan_connect` returns: the request just made, the one still pending (after waiting up to
|
|
138
|
+
* `awaitMs` for it), or why there is none. Calls run one at a time.
|
|
139
|
+
*/
|
|
140
|
+
ensureAccess(title) {
|
|
141
|
+
const run = this.ensureQueue.then(() => this.ensureAccessNow(title));
|
|
142
|
+
this.ensureQueue = run.catch(() => undefined);
|
|
143
|
+
return run;
|
|
144
|
+
}
|
|
145
|
+
async ensureAccessNow(title) {
|
|
146
|
+
if (this.credential !== null)
|
|
147
|
+
return { signedIn: true };
|
|
148
|
+
const sessionId = this.deps.sessionId;
|
|
149
|
+
if (sessionId === undefined || sessionId === "") {
|
|
150
|
+
return notReady({ state: "no_session_id", error: NO_SESSION_ID_ERROR }, true);
|
|
151
|
+
}
|
|
152
|
+
let note;
|
|
153
|
+
if (this.pending !== null) {
|
|
154
|
+
const pending = this.pending;
|
|
155
|
+
const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
|
|
156
|
+
if (outcome === null)
|
|
157
|
+
return notReady(this.requestAnswer("approval_pending", pending.request), false);
|
|
158
|
+
this.pending = null;
|
|
159
|
+
switch (outcome.kind) {
|
|
160
|
+
case "approved":
|
|
161
|
+
return { signedIn: true };
|
|
162
|
+
case "denied":
|
|
163
|
+
return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
|
|
164
|
+
case "failed":
|
|
165
|
+
return notReady({ state: "request_failed", error: outcome.error }, true);
|
|
166
|
+
case "expired":
|
|
167
|
+
note = EXPIRED_NOTE;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return this.createRequest(sessionId, title, note);
|
|
171
|
+
}
|
|
172
|
+
async createRequest(sessionId, title, note) {
|
|
173
|
+
const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
|
|
174
|
+
kind: "session",
|
|
175
|
+
labelId: sessionId,
|
|
176
|
+
title: title ?? sessionId.slice(0, TITLE_FALLBACK_LENGTH),
|
|
177
|
+
repo: this.deps.repo,
|
|
178
|
+
machine: this.deps.machine ?? hostname(),
|
|
179
|
+
clientVersion: this.deps.clientVersion,
|
|
180
|
+
});
|
|
181
|
+
switch (created.kind) {
|
|
182
|
+
case "dashboard_outdated":
|
|
183
|
+
return notReady({ state: "dashboard_outdated", error: DASHBOARD_OUTDATED_ERROR }, true);
|
|
184
|
+
case "rate_limited":
|
|
185
|
+
return notReady({ state: "rate_limited", retryAfterSeconds: created.retryAfterSeconds, error: RATE_LIMITED_ERROR }, true);
|
|
186
|
+
case "refused":
|
|
187
|
+
return notReady({ state: "request_refused", status: created.status, body: created.body }, true);
|
|
188
|
+
case "created":
|
|
189
|
+
this.pending = { request: created.request, settled: this.pollUntilSettled(created.request) };
|
|
190
|
+
return notReady({ ...this.requestAnswer("approval_required", created.request), ...(note ? { note } : {}) }, false);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
requestAnswer(state, request) {
|
|
194
|
+
return {
|
|
195
|
+
state,
|
|
196
|
+
approvalUrl: request.approvalUrl,
|
|
197
|
+
confirmCode: request.confirmCode,
|
|
198
|
+
expiresAt: request.expiresAt,
|
|
199
|
+
instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async pollUntilSettled(request) {
|
|
203
|
+
const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
|
|
204
|
+
let waitMs = request.pollMs;
|
|
205
|
+
for (;;) {
|
|
206
|
+
await this.sleep(waitMs);
|
|
207
|
+
waitMs = request.pollMs;
|
|
208
|
+
if (this.now() > giveUpAt)
|
|
209
|
+
return { kind: "expired" };
|
|
210
|
+
let claim;
|
|
211
|
+
try {
|
|
212
|
+
claim = await claimAccessRequest(this.fetchImpl, this.deps.dashboardUrl, request.secret);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
// A claim the dashboard could not answer is retried until the request lapses; an outdated
|
|
216
|
+
// MCP will never get a different answer, so that one ends it.
|
|
217
|
+
if (err instanceof McpOutdatedError)
|
|
218
|
+
return { kind: "failed", error: err.message };
|
|
219
|
+
this.deps.log?.(`[danx-dashboard-mcp] access request poll failed, retrying: ${err.message}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
switch (claim.kind) {
|
|
223
|
+
case "pending":
|
|
224
|
+
continue;
|
|
225
|
+
case "rate_limited":
|
|
226
|
+
waitMs = Math.max(request.pollMs, claim.retryAfterSeconds * 1000);
|
|
227
|
+
continue;
|
|
228
|
+
case "approved":
|
|
229
|
+
return this.storeKey(claim.apiKey);
|
|
230
|
+
case "denied":
|
|
231
|
+
return { kind: "denied" };
|
|
232
|
+
case "expired":
|
|
233
|
+
case "not_found":
|
|
234
|
+
return { kind: "expired" };
|
|
235
|
+
case "claimed":
|
|
236
|
+
return { kind: "failed", error: CLAIMED_LOST_ERROR };
|
|
237
|
+
case "refused":
|
|
238
|
+
return {
|
|
239
|
+
kind: "failed",
|
|
240
|
+
error: `the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
storeKey(apiKey) {
|
|
246
|
+
const now = this.now();
|
|
247
|
+
try {
|
|
248
|
+
const path = writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey }, { home: this.deps.home, now });
|
|
249
|
+
this.credential = {
|
|
250
|
+
source: { kind: "session-key", path },
|
|
251
|
+
token: apiKey,
|
|
252
|
+
fingerprint: credentialFingerprint(apiKey),
|
|
253
|
+
};
|
|
254
|
+
this.lastKeyFileWrite = now;
|
|
255
|
+
this.deps.setToken(apiKey);
|
|
256
|
+
return { kind: "approved" };
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return {
|
|
260
|
+
kind: "failed",
|
|
261
|
+
error: `the request was approved but storing the key failed: ${err.message}; start a new request`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the server's, every event was dropped silently (the card's incident).
|
|
11
11
|
*
|
|
12
12
|
* WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
|
|
13
|
-
* writes the dashboard URL, the credential SOURCE (a file path, or the name of
|
|
13
|
+
* writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
|
|
14
14
|
* an env var — never a credential) and the credential's FINGERPRINT. The bridge
|
|
15
15
|
* reads that record, resolves the SAME source through the same resolver
|
|
16
16
|
* (`credential.ts`), and refuses to run unless what it resolved fingerprints
|
|
@@ -36,7 +36,9 @@ import { join, resolve, win32 } from "node:path";
|
|
|
36
36
|
// DX-3928 — v2 adds `projectKey`, the directory the connecting session ran in, so a
|
|
37
37
|
// restarted session (a NEW session id, no record of its own) can find which plan the
|
|
38
38
|
// previous session of the same project was on (`findPredecessorCandidates`).
|
|
39
|
-
export const SESSION_CONNECTION_SCHEMA_VERSION =
|
|
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.224",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|