@thehammer/danx-dashboard-mcp 0.1.75 → 0.1.76
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 +5 -4
- package/dist/bridge.js +327 -84
- package/dist/credential.js +125 -0
- package/dist/entrypoint.js +0 -0
- package/dist/entrypoint.test.js +0 -0
- package/dist/handlers.js +0 -0
- package/dist/http-client.js +0 -0
- package/dist/index.js +58 -3
- package/dist/listen.js +0 -0
- package/dist/one-line.js +0 -0
- package/dist/priority.js +0 -0
- package/dist/session-connection.js +176 -0
- package/dist/tool-defs.js +0 -0
- package/dist/types.js +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Each tool is a thin envelope over one HTTP route — Zod-validated at the MCP bo
|
|
|
9
9
|
| Var | Purpose |
|
|
10
10
|
|---|---|
|
|
11
11
|
| `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
|
|
12
|
-
| `DANXBOT_DISPATCH_TOKEN` |
|
|
12
|
+
| `DANXBOT_DASHBOARD_TOKEN_FILE` **or** `DANXBOT_DISPATCH_TOKEN` | The credential, declared exactly one of two ways (DX-2862, `src/credential.ts`). A FILE holding the bearer (a leading `~` is the user's home) — how an interactive operator session declares one, since its `.mcp.json` is git-tracked and can never carry the token itself. Or the bearer VERBATIM — how a dispatch declares one, substituted from its overlay. A declared file is AUTHORITATIVE and its failure is FATAL: an inherited `DANXBOT_DISPATCH_TOKEN` is never consulted instead, because on an operator machine that value routinely names a different (local-dev) dashboard, which answers healthily and empty. The same resolver serves the `bridge` subcommand below, so a session's tools and its event stream can never sign in as two different identities |
|
|
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
|
|
|
@@ -56,11 +56,12 @@ Evaluated in that order (`complete` > `awaiting-session` > `building` > `plannin
|
|
|
56
56
|
A program runs this, not an agent: the danxbot Claude Code plugin's plan event bridge spawns `bridge` for a session, relays each event's `text` into the session, and stores the delivered ids. `plan_connect` only connects the session; it mints no ticket.
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
-
|
|
60
|
-
npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
|
|
59
|
+
CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
|
|
61
60
|
```
|
|
62
61
|
|
|
63
|
-
- **Credential.** The
|
|
62
|
+
- **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 `DANXBOT_DISPATCH_TOKEN` 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.
|
|
63
|
+
- **Reach check.** After minting, `bridge` proves the credential can read every board the connected plan's cards live on (`GET /api/plans/mine?fields=cards`, then `GET /api/issues/:id/problems` for one card per board — the routes that run the same board-allowlist + `read` check the stream itself applies). A board it cannot read stops it with `board_unreadable` rather than streaming on and dropping that board's events. Then, once, `{"type":"ready","boards":[…]}`.
|
|
64
|
+
- **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.
|
|
64
65
|
- **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.
|
|
65
66
|
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…"}` and exit, only on a terminal outcome: `not_connected`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0), `revoked`, or `refused` (two freshly minted tickets refused in a row). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
|
|
66
67
|
- **Reconnect and resume.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
|
package/dist/bridge.js
CHANGED
|
@@ -5,106 +5,193 @@
|
|
|
5
5
|
* WHO RUNS IT. The danxbot Claude Code plugin's plan event bridge
|
|
6
6
|
* (`danxbot/scripts/plan-event-bridge.mjs` in claude-plugins) spawns it once per
|
|
7
7
|
* session and owns only process lifecycle, the delivered-id cursor, and posting
|
|
8
|
-
* each event into the session's inbox. Everything about the
|
|
9
|
-
* contract lives HERE, next to the MCP server that already
|
|
10
|
-
* tested here.
|
|
8
|
+
* each event (and each failure) into the session's inbox. Everything about the
|
|
9
|
+
* dashboard's HTTP contract lives HERE, next to the MCP server that already
|
|
10
|
+
* speaks it, and is tested here.
|
|
11
11
|
*
|
|
12
12
|
* WHAT IT DOES.
|
|
13
|
-
* 1. Reads `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
13
|
+
* 1. Reads `CLAUDE_CODE_SESSION_ID` from its environment and, for that
|
|
14
|
+
* session, the connection record the session's own MCP server wrote when
|
|
15
|
+
* it connected the plan (`session-connection.ts`): the dashboard URL, the
|
|
16
|
+
* credential's SOURCE, and the credential's FINGERPRINT.
|
|
17
|
+
* 2. Resolves that same source through the same resolver the server used
|
|
18
|
+
* (`credential.ts`) and refuses to run unless the result fingerprints
|
|
19
|
+
* identically — DX-2862, where a bridge authenticated with a DIFFERENT
|
|
20
|
+
* credential from its session's tools streamed happily and relayed nothing.
|
|
21
|
+
* 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
|
|
22
|
+
* The ticket stays in this process.
|
|
23
|
+
* 4. VERIFIES THE REACH IT JUST BOUGHT: the credential must be able to read
|
|
24
|
+
* every board the connected plan's cards live on, because the stream admits
|
|
25
|
+
* an event only when the ticket issuer's board allowlist covers that
|
|
26
|
+
* event's board (`isVisibleToStream`, dashboard). A board it cannot read is
|
|
27
|
+
* a board whose events would vanish without a word, so it is a terminal,
|
|
28
|
+
* named outcome instead.
|
|
29
|
+
* 5. Streams through `runListener` (`listen.ts`), which reconnects within the
|
|
30
|
+
* ticket, and re-mints when that ticket's lease runs out or a fresh ticket
|
|
31
|
+
* is refused once.
|
|
32
|
+
* 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards}` once the stream
|
|
33
|
+
* is live and verified, `{type:"event", id, text}` per event, and ONE final
|
|
34
|
+
* `{type:"stopped", reason, detail, fix}` before exiting.
|
|
22
35
|
*
|
|
23
36
|
* TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
|
|
24
|
-
* - `
|
|
25
|
-
* - `
|
|
26
|
-
* - `
|
|
27
|
-
* - `
|
|
37
|
+
* - `no_connection_record` this session has no connection record to read;
|
|
38
|
+
* - `credential_unavailable` its declared credential cannot be resolved here;
|
|
39
|
+
* - `credential_mismatch` what resolved is not the server's credential;
|
|
40
|
+
* - `not_connected` the mint answered 409 `session_not_connected`;
|
|
41
|
+
* - `unauthorized` the mint or a scope read answered 401 / 403;
|
|
42
|
+
* - `mint_refused` any other non-transient mint refusal (400, 404, …);
|
|
43
|
+
* - `mint_bad_response` a 2xx mint whose body is not a ticket;
|
|
44
|
+
* - `board_unreadable` the credential cannot read a board of this plan;
|
|
45
|
+
* - `scope_check_failed` the plan's boards could not be established;
|
|
28
46
|
* - `superseded` / `replaced` (exit 0) — another listener now holds the session;
|
|
29
|
-
* - `revoked`
|
|
30
|
-
* - `refused`
|
|
31
|
-
* Transient
|
|
47
|
+
* - `revoked` the dashboard revoked the ticket or its issuer;
|
|
48
|
+
* - `refused` freshly minted tickets were refused twice in a row.
|
|
49
|
+
* Transient failures (network, timeout, 408, 429, 5xx) back off and retry: the
|
|
32
50
|
* process lives exactly as long as its session, and the plugin ends it.
|
|
33
51
|
*
|
|
34
|
-
* THE ENVIRONMENT
|
|
35
|
-
*
|
|
36
|
-
*
|
|
52
|
+
* NEITHER THE ENVIRONMENT NOR ARGV CARRIES THE CREDENTIAL ANY MORE. `--resume-ids`
|
|
53
|
+
* is the only argument and holds no secret; the credential is whatever this
|
|
54
|
+
* session's own MCP server declared, resolved here from that declaration.
|
|
37
55
|
*/
|
|
56
|
+
import { homedir } from "node:os";
|
|
38
57
|
import { oneLine } from "./one-line.js";
|
|
58
|
+
import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
|
|
59
|
+
import { readSessionConnection } from "./session-connection.js";
|
|
39
60
|
import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
|
|
40
61
|
export const BRIDGE_SUBCOMMAND = "bridge";
|
|
41
62
|
export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
|
|
63
|
+
export const PLAN_MINE_PATH = "/api/plans/mine";
|
|
64
|
+
export const ISSUES_PATH = "/api/issues";
|
|
42
65
|
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
43
|
-
/** How long one
|
|
44
|
-
export const
|
|
66
|
+
/** How long one dashboard request may take before it counts as a transient failure. */
|
|
67
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
45
68
|
export const MINT_INITIAL_BACKOFF_MS = 1_000;
|
|
46
69
|
export const MINT_MAX_BACKOFF_MS = 60_000;
|
|
47
70
|
/** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
|
|
48
71
|
export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
|
|
72
|
+
/** One page of the connected plan's cards, for the board-reach check. The server's ceiling. */
|
|
73
|
+
export const PLAN_CARDS_PAGE_SIZE = 1_000;
|
|
49
74
|
const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
50
75
|
const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
|
|
51
|
-
const USAGE = `usage:
|
|
52
|
-
`
|
|
76
|
+
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
|
|
77
|
+
`[--resume-ids <event-id>[,<event-id>...]]`;
|
|
78
|
+
/**
|
|
79
|
+
* The remedy per terminal reason. ONE map, so the message a session is shown and
|
|
80
|
+
* the message a log carries can never drift, and a new reason without a remedy
|
|
81
|
+
* is a compile-visible omission rather than a silent blank.
|
|
82
|
+
*/
|
|
83
|
+
export const STOP_FIXES = {
|
|
84
|
+
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
|
|
85
|
+
"(a server older than the one that introduced the record writes none)",
|
|
86
|
+
credential_unavailable: "fix the credential this session's danx-dashboard MCP server declares, then call plan_connect again",
|
|
87
|
+
credential_mismatch: "restart this Claude Code session so its MCP server and its bridge pick up the same credential, then call plan_connect again",
|
|
88
|
+
not_connected: "call plan_connect to connect this session to a plan",
|
|
89
|
+
unauthorized: "give this session's danx-dashboard MCP server a dashboard credential that is accepted, then call plan_connect again",
|
|
90
|
+
mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
|
|
91
|
+
mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
|
|
92
|
+
board_unreadable: "scope this session's dashboard credential to read that board, or connect a plan whose cards all live on boards it can read",
|
|
93
|
+
scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
|
|
94
|
+
revoked: "call plan_connect again to mint a new listener ticket for this session",
|
|
95
|
+
refused: "call plan_connect again to mint a new listener ticket for this session",
|
|
96
|
+
};
|
|
97
|
+
export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the bridge";
|
|
98
|
+
export function fixForStop(reason) {
|
|
99
|
+
return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
|
|
100
|
+
}
|
|
53
101
|
function positiveInteger(raw) {
|
|
54
102
|
const n = Number(raw);
|
|
55
103
|
return Number.isSafeInteger(n) && n > 0 && String(n) === raw ? n : null;
|
|
56
104
|
}
|
|
57
|
-
/** Parse the one optional flag and the
|
|
105
|
+
/** Parse the one optional flag and the one required environment value; anything else is refused. */
|
|
58
106
|
export function parseBridgeArgs(argv, env) {
|
|
59
107
|
const withResume = argv.length === 2 && argv[0] === "--resume-ids" && argv[1] !== "";
|
|
60
108
|
if (argv.length !== 0 && !withResume)
|
|
61
109
|
throw new Error(USAGE);
|
|
62
|
-
const dashboardUrl = env.DANXBOT_DASHBOARD_URL;
|
|
63
|
-
const token = env.DANXBOT_DISPATCH_TOKEN;
|
|
64
110
|
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
65
|
-
if (!
|
|
111
|
+
if (!sessionId)
|
|
66
112
|
throw new Error(USAGE);
|
|
67
113
|
const resumeIds = withResume ? argv[1].split(",").map(positiveInteger) : [];
|
|
68
114
|
if (resumeIds.some((id) => id === null))
|
|
69
115
|
throw new Error(USAGE);
|
|
70
|
-
return {
|
|
116
|
+
return { sessionId, resumeIds: resumeIds };
|
|
117
|
+
}
|
|
118
|
+
/** A start this process cannot make, named the way the session will be told about it. */
|
|
119
|
+
export class BridgeStartError extends Error {
|
|
120
|
+
reason;
|
|
121
|
+
fix;
|
|
122
|
+
constructor(reason, detail, fix) {
|
|
123
|
+
super(detail);
|
|
124
|
+
this.name = "BridgeStartError";
|
|
125
|
+
this.reason = reason;
|
|
126
|
+
this.fix = fix;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The session's connection, as its own MCP server recorded it — and the proof
|
|
131
|
+
* that this process holds the SAME credential that server does. Never falls back
|
|
132
|
+
* to an ambient `DANXBOT_DISPATCH_TOKEN`: that fallback is precisely DX-2862.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveBridgeOptions(args, env, options = {}) {
|
|
135
|
+
const home = options.home ?? homedir();
|
|
136
|
+
let record;
|
|
137
|
+
try {
|
|
138
|
+
record = readSessionConnection(args.sessionId, { home });
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
|
|
142
|
+
}
|
|
143
|
+
if (record === null) {
|
|
144
|
+
throw new BridgeStartError("no_connection_record", `session ${args.sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"));
|
|
145
|
+
}
|
|
146
|
+
let token;
|
|
147
|
+
try {
|
|
148
|
+
token = readCredential(record.source, env);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const credentialError = err;
|
|
152
|
+
throw new BridgeStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`, credentialError.fix ?? fixForStop("credential_unavailable"));
|
|
153
|
+
}
|
|
154
|
+
const fingerprint = credentialFingerprint(token);
|
|
155
|
+
if (fingerprint !== record.fingerprint) {
|
|
156
|
+
throw new BridgeStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
|
|
157
|
+
`danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"));
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""),
|
|
161
|
+
token,
|
|
162
|
+
sessionId: args.sessionId,
|
|
163
|
+
resumeIds: args.resumeIds,
|
|
164
|
+
};
|
|
71
165
|
}
|
|
72
166
|
function errorCodeOf(body) {
|
|
73
167
|
return typeof body === "object" && body !== null && typeof body.error === "string"
|
|
74
168
|
? body.error
|
|
75
169
|
: null;
|
|
76
170
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
|
|
85
|
-
return "streamPath is not a path";
|
|
86
|
-
if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
|
|
87
|
-
return "leaseMs is not a positive integer";
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
/** Mint this session's listener ticket, classifying every way that can end. */
|
|
92
|
-
export async function mintTicket(options, deps) {
|
|
171
|
+
/**
|
|
172
|
+
* One authenticated request, with a timeout, classifying the failures that are
|
|
173
|
+
* about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by the
|
|
174
|
+
* mint and the board-reach check so the two can never disagree about which
|
|
175
|
+
* failures are worth retrying.
|
|
176
|
+
*/
|
|
177
|
+
async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
93
178
|
const controller = new AbortController();
|
|
94
|
-
const timer = setTimeout(() => controller.abort(), deps.
|
|
179
|
+
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
95
180
|
try {
|
|
96
181
|
let status;
|
|
97
182
|
let text;
|
|
98
183
|
try {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
184
|
+
const headers = {
|
|
185
|
+
Authorization: `Bearer ${options.token}`,
|
|
186
|
+
Accept: "application/json",
|
|
187
|
+
[SESSION_ID_HEADER]: options.sessionId,
|
|
188
|
+
};
|
|
189
|
+
if (init.body !== undefined)
|
|
190
|
+
headers["Content-Type"] = "application/json";
|
|
191
|
+
const response = await deps.fetch(url, {
|
|
192
|
+
method: init.method,
|
|
193
|
+
headers,
|
|
194
|
+
body: init.body,
|
|
108
195
|
signal: controller.signal,
|
|
109
196
|
});
|
|
110
197
|
status = response.status;
|
|
@@ -114,12 +201,12 @@ export async function mintTicket(options, deps) {
|
|
|
114
201
|
return {
|
|
115
202
|
kind: "transient",
|
|
116
203
|
detail: controller.signal.aborted
|
|
117
|
-
?
|
|
118
|
-
:
|
|
204
|
+
? `${what} timed out after ${deps.requestTimeoutMs} ms`
|
|
205
|
+
: `${what} failed: ${err.message}`,
|
|
119
206
|
};
|
|
120
207
|
}
|
|
121
208
|
if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
|
|
122
|
-
return { kind: "transient", detail:
|
|
209
|
+
return { kind: "transient", detail: `${what} HTTP ${status}` };
|
|
123
210
|
}
|
|
124
211
|
let body = null;
|
|
125
212
|
try {
|
|
@@ -128,43 +215,168 @@ export async function mintTicket(options, deps) {
|
|
|
128
215
|
catch {
|
|
129
216
|
body = null;
|
|
130
217
|
}
|
|
218
|
+
return { kind: "answer", status, text, body };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
|
|
225
|
+
function badTicketReason(body) {
|
|
226
|
+
if (typeof body !== "object" || body === null)
|
|
227
|
+
return "the body is not a JSON object";
|
|
228
|
+
const b = body;
|
|
229
|
+
if (typeof b.ticket !== "string" || b.ticket === "")
|
|
230
|
+
return "ticket is not a non-empty string";
|
|
231
|
+
if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
|
|
232
|
+
return "streamPath is not a path";
|
|
233
|
+
if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
|
|
234
|
+
return "leaseMs is not a positive integer";
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/** Mint this session's listener ticket, classifying every way that can end. */
|
|
239
|
+
export async function mintTicket(options, deps) {
|
|
240
|
+
const outcome = await request(options, deps, "ticket mint", `${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
body: "{}",
|
|
243
|
+
});
|
|
244
|
+
if (outcome.kind === "transient")
|
|
245
|
+
return outcome;
|
|
246
|
+
const { status, text, body } = outcome;
|
|
247
|
+
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
248
|
+
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
249
|
+
}
|
|
250
|
+
if (status === 401 || status === 403) {
|
|
251
|
+
return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
|
|
252
|
+
}
|
|
253
|
+
if (status < 200 || status >= 300) {
|
|
254
|
+
return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
|
|
255
|
+
}
|
|
256
|
+
const bad = badTicketReason(body);
|
|
257
|
+
if (bad !== null) {
|
|
258
|
+
return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
|
|
259
|
+
}
|
|
260
|
+
const ticket = body;
|
|
261
|
+
return {
|
|
262
|
+
kind: "ticket",
|
|
263
|
+
ticket: ticket.ticket,
|
|
264
|
+
streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
|
|
265
|
+
leaseMs: ticket.leaseMs,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** One card per board of the connected plan, read a page at a time. */
|
|
269
|
+
function cardsPageOf(body) {
|
|
270
|
+
if (typeof body !== "object" || body === null)
|
|
271
|
+
return "the body is not a JSON object";
|
|
272
|
+
const b = body;
|
|
273
|
+
if (!Array.isArray(b.cards))
|
|
274
|
+
return "cards is not an array";
|
|
275
|
+
if (typeof b.cards_total !== "number" || !Number.isSafeInteger(b.cards_total))
|
|
276
|
+
return "cards_total is not an integer";
|
|
277
|
+
const cards = [];
|
|
278
|
+
for (const card of b.cards) {
|
|
279
|
+
if (typeof card !== "object" || card === null)
|
|
280
|
+
return "a card is not an object";
|
|
281
|
+
const c = card;
|
|
282
|
+
if (typeof c.id !== "string" || typeof c.boardId !== "string")
|
|
283
|
+
return "a card has no id / boardId";
|
|
284
|
+
cards.push({ id: c.id, boardId: c.boardId });
|
|
285
|
+
}
|
|
286
|
+
return { cards, total: b.cards_total };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Prove this credential can read every board the connected plan's cards live on.
|
|
290
|
+
*
|
|
291
|
+
* WHY THIS EXISTS AT ALL. The stream admits an event only when the ticket
|
|
292
|
+
* issuer's board allowlist covers the event's board and the issuer still holds
|
|
293
|
+
* `read` (`isVisibleToStream` / `resolveIssuerScope`, dashboard). A credential
|
|
294
|
+
* short of that streams exactly as happily as a correct one and simply never
|
|
295
|
+
* relays those boards' events — silence that looks identical to "nothing has
|
|
296
|
+
* happened yet".
|
|
297
|
+
*
|
|
298
|
+
* WHY THESE TWO ROUTES. `GET /api/plans/mine?fields=cards` is the only read that
|
|
299
|
+
* names the connected plan's cards, and `GET /api/issues/:id/problems` is a card
|
|
300
|
+
* read that runs the SAME board-allowlist + `read` check the stream does
|
|
301
|
+
* (`withV2Handler` + `resolveIssueBoardId`). Deliberately NOT `GET /api/issues`
|
|
302
|
+
* or `GET /api/issues/:id`: neither checks the caller's board allowlist at all,
|
|
303
|
+
* so a check built on them would pass precisely where the stream drops events.
|
|
304
|
+
*/
|
|
305
|
+
export async function verifyPlanBoardsReadable(options, deps) {
|
|
306
|
+
const firstCardPerBoard = new Map();
|
|
307
|
+
let offset = 0;
|
|
308
|
+
for (;;) {
|
|
309
|
+
const url = `${options.dashboardUrl}${PLAN_MINE_PATH}?fields=cards&cards_limit=${PLAN_CARDS_PAGE_SIZE}&cards_offset=${offset}`;
|
|
310
|
+
const outcome = await request(options, deps, "plan read", url);
|
|
311
|
+
if (outcome.kind === "transient")
|
|
312
|
+
return outcome;
|
|
313
|
+
const { status, text, body } = outcome;
|
|
131
314
|
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
132
315
|
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
133
316
|
}
|
|
134
317
|
if (status === 401 || status === 403) {
|
|
135
|
-
return { kind: "terminal", reason: "unauthorized", detail: `
|
|
318
|
+
return { kind: "terminal", reason: "unauthorized", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
|
|
136
319
|
}
|
|
137
320
|
if (status < 200 || status >= 300) {
|
|
138
|
-
return { kind: "terminal", reason: "
|
|
321
|
+
return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
|
|
139
322
|
}
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
return { kind: "terminal", reason: "
|
|
323
|
+
const page = cardsPageOf(body);
|
|
324
|
+
if (typeof page === "string") {
|
|
325
|
+
return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status}: ${page}` };
|
|
143
326
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
327
|
+
for (const card of page.cards) {
|
|
328
|
+
if (!firstCardPerBoard.has(card.boardId))
|
|
329
|
+
firstCardPerBoard.set(card.boardId, card.id);
|
|
330
|
+
}
|
|
331
|
+
offset += page.cards.length;
|
|
332
|
+
if (page.cards.length === 0 || offset >= page.total)
|
|
333
|
+
break;
|
|
151
334
|
}
|
|
152
|
-
|
|
153
|
-
|
|
335
|
+
for (const [boardId, cardId] of firstCardPerBoard) {
|
|
336
|
+
const url = `${options.dashboardUrl}${ISSUES_PATH}/${encodeURIComponent(cardId)}/problems?board=${encodeURIComponent(boardId)}`;
|
|
337
|
+
const outcome = await request(options, deps, `board read of ${boardId}`, url);
|
|
338
|
+
if (outcome.kind === "transient")
|
|
339
|
+
return outcome;
|
|
340
|
+
const { status, text } = outcome;
|
|
341
|
+
if (status === 403) {
|
|
342
|
+
return {
|
|
343
|
+
kind: "terminal",
|
|
344
|
+
reason: "board_unreadable",
|
|
345
|
+
detail: `this session's dashboard credential cannot read board ${boardId}, which the connected plan's card ` +
|
|
346
|
+
`${cardId} lives on — the event stream drops that board's events silently (${oneLine(text, 200)})`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (status === 401) {
|
|
350
|
+
return { kind: "terminal", reason: "unauthorized", detail: `board read of ${boardId} HTTP 401 ${oneLine(text, 200)}` };
|
|
351
|
+
}
|
|
352
|
+
if (status === 404) {
|
|
353
|
+
// The card moved or was deleted between the two reads. Nothing is wrong
|
|
354
|
+
// with the credential — re-read the plan rather than accuse it.
|
|
355
|
+
return { kind: "transient", detail: `card ${cardId} vanished between the plan read and the board read` };
|
|
356
|
+
}
|
|
357
|
+
if (status < 200 || status >= 300) {
|
|
358
|
+
return {
|
|
359
|
+
kind: "terminal",
|
|
360
|
+
reason: "scope_check_failed",
|
|
361
|
+
detail: `board read of ${boardId} HTTP ${status} ${oneLine(text, 200)}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
154
364
|
}
|
|
365
|
+
return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
|
|
155
366
|
}
|
|
156
367
|
/**
|
|
157
|
-
* Mint, stream, re-mint — until a terminal outcome, which it writes as
|
|
158
|
-
* `stopped` record. Returns 0 when another listener took over, 1 otherwise.
|
|
368
|
+
* Mint, verify, stream, re-mint — until a terminal outcome, which it writes as
|
|
369
|
+
* the one `stopped` record. Returns 0 when another listener took over, 1 otherwise.
|
|
159
370
|
*/
|
|
160
371
|
export async function runBridge(options, deps) {
|
|
161
372
|
const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
|
|
162
373
|
const stop = (reason, detail, code) => {
|
|
163
|
-
deps.write({ type: "stopped", reason, detail });
|
|
374
|
+
deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
|
|
164
375
|
return code;
|
|
165
376
|
};
|
|
166
377
|
let backoff = MINT_INITIAL_BACKOFF_MS;
|
|
167
378
|
let refusedInRow = 0;
|
|
379
|
+
let verified = false;
|
|
168
380
|
for (;;) {
|
|
169
381
|
const minted = await mintTicket(options, deps);
|
|
170
382
|
if (minted.kind === "terminal")
|
|
@@ -174,6 +386,20 @@ export async function runBridge(options, deps) {
|
|
|
174
386
|
backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
|
|
175
387
|
continue;
|
|
176
388
|
}
|
|
389
|
+
// The reach check runs on the FIRST ticket only: it is a property of the
|
|
390
|
+
// credential and the plan, and a re-mint (a lapsed lease) changes neither.
|
|
391
|
+
if (!verified) {
|
|
392
|
+
const scope = await verifyPlanBoardsReadable(options, deps);
|
|
393
|
+
if (scope.kind === "terminal")
|
|
394
|
+
return stop(scope.reason, scope.detail, 1);
|
|
395
|
+
if (scope.kind === "transient") {
|
|
396
|
+
await deps.sleep(backoff);
|
|
397
|
+
backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
verified = true;
|
|
401
|
+
deps.write({ type: "ready", boards: scope.boards });
|
|
402
|
+
}
|
|
177
403
|
backoff = MINT_INITIAL_BACKOFF_MS;
|
|
178
404
|
const run = { stopped: null, emitted: false };
|
|
179
405
|
const startedAt = deps.now();
|
|
@@ -214,23 +440,40 @@ export async function runBridge(options, deps) {
|
|
|
214
440
|
}
|
|
215
441
|
}
|
|
216
442
|
/** The bin's `bridge` subcommand, wired to the real process. */
|
|
217
|
-
export async function runBridgeCommand(argv, env = process.env
|
|
218
|
-
|
|
443
|
+
export async function runBridgeCommand(argv, env = process.env,
|
|
444
|
+
/** Test seam: the home the session's connection record is read from. */
|
|
445
|
+
resolveFrom = {}) {
|
|
446
|
+
const write = (output) => {
|
|
447
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
448
|
+
};
|
|
449
|
+
let args;
|
|
219
450
|
try {
|
|
220
|
-
|
|
451
|
+
args = parseBridgeArgs(argv, env);
|
|
221
452
|
}
|
|
222
453
|
catch (err) {
|
|
223
454
|
process.stderr.write(`${err.message}\n`);
|
|
224
455
|
return 2;
|
|
225
456
|
}
|
|
457
|
+
let options;
|
|
458
|
+
try {
|
|
459
|
+
options = resolveBridgeOptions(args, env, resolveFrom);
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
const start = err;
|
|
463
|
+
// A start that never happened still reports itself the way every other
|
|
464
|
+
// terminal outcome does — one stop record the plugin relays into the
|
|
465
|
+
// session — AND on stderr, for the case where there is no session to relay
|
|
466
|
+
// into (a hand run).
|
|
467
|
+
write({ type: "stopped", reason: start.reason, detail: start.message, fix: start.fix });
|
|
468
|
+
process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
226
471
|
return runBridge(options, {
|
|
227
472
|
fetch: (input, init) => fetch(input, init),
|
|
228
|
-
write
|
|
229
|
-
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
230
|
-
},
|
|
473
|
+
write,
|
|
231
474
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
232
475
|
now: Date.now,
|
|
233
476
|
readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
|
|
234
|
-
|
|
477
|
+
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
|
235
478
|
});
|
|
236
479
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2862 — the ONE resolver for this package's dashboard credential.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT IS ONE FUNCTION AND NOT TWO. Two processes speak to the dashboard for
|
|
5
|
+
* the same Claude Code session: the MCP SERVER (the session's card + plan tools)
|
|
6
|
+
* and `danx-dashboard-mcp bridge` (the session's event stream, run by the
|
|
7
|
+
* danxbot plugin's plan event bridge). Before this module they resolved their
|
|
8
|
+
* credential independently — the server through danxbot's operator launcher,
|
|
9
|
+
* which read a token file, and the bridge from whatever `DANXBOT_DISPATCH_TOKEN`
|
|
10
|
+
* happened to be in the session's environment. On a machine where those two
|
|
11
|
+
* differ (DX-2862: `ffe9f8a882f0…` in the environment, `6f9bc6e94b83…` in
|
|
12
|
+
* `~/.config/danxbot/dashboard-token-gpt`) the session's tools worked, the
|
|
13
|
+
* bridge's stream was admitted, and every operator comment was dropped with no
|
|
14
|
+
* error anywhere. So resolution lives HERE, both callers go through it, and the
|
|
15
|
+
* bridge additionally PROVES it holds the server's credential by comparing
|
|
16
|
+
* fingerprints (`session-connection.ts`).
|
|
17
|
+
*
|
|
18
|
+
* THE DECLARATION. A server's env declares where its credential comes from:
|
|
19
|
+
*
|
|
20
|
+
* - `DANXBOT_DASHBOARD_TOKEN_FILE` — a path (a leading `~` is this user's
|
|
21
|
+
* home). The file's trimmed contents are the credential. This is how an
|
|
22
|
+
* interactive operator session declares one, because its `.mcp.json` is
|
|
23
|
+
* git-tracked and can never carry the bearer itself.
|
|
24
|
+
* - `DANXBOT_DISPATCH_TOKEN` — the credential verbatim. This is how a
|
|
25
|
+
* DISPATCH declares one: the worker substitutes it into the materialized
|
|
26
|
+
* `.mcp.json` from the dispatch overlay.
|
|
27
|
+
*
|
|
28
|
+
* A DECLARED FILE IS AUTHORITATIVE, AND ITS FAILURE IS FATAL. When the file
|
|
29
|
+
* variable is set, `DANXBOT_DISPATCH_TOKEN` is never consulted — not even when
|
|
30
|
+
* the file is missing or empty. That is the DX-2483 rule, moved here from the
|
|
31
|
+
* launcher it used to live in: an operator machine routinely carries an
|
|
32
|
+
* inherited `DANXBOT_DISPATCH_TOKEN` 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.
|
|
36
|
+
*/
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { homedir } from "node:os";
|
|
40
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
41
|
+
export const CREDENTIAL_FILE_ENV = "DANXBOT_DASHBOARD_TOKEN_FILE";
|
|
42
|
+
export const CREDENTIAL_TOKEN_ENV = "DANXBOT_DISPATCH_TOKEN";
|
|
43
|
+
/**
|
|
44
|
+
* A credential that cannot be resolved. Carries the remedy beside the reason,
|
|
45
|
+
* because both of this module's callers report failures to a human who is not
|
|
46
|
+
* reading a log: the server exits with it on stderr, and the bridge posts it
|
|
47
|
+
* into the session's inbox.
|
|
48
|
+
*/
|
|
49
|
+
export class CredentialError extends Error {
|
|
50
|
+
fix;
|
|
51
|
+
constructor(detail, fix) {
|
|
52
|
+
super(detail);
|
|
53
|
+
this.name = "CredentialError";
|
|
54
|
+
this.fix = fix;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
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
|
+
function readEnv(env, name) {
|
|
68
|
+
const value = env[name];
|
|
69
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
70
|
+
}
|
|
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
|
+
/** One line naming a source, for a message a human reads. Holds no secret. */
|
|
86
|
+
export function describeCredentialSource(source) {
|
|
87
|
+
return source.kind === "file" ? `the token file ${source.path}` : `the environment variable ${source.name}`;
|
|
88
|
+
}
|
|
89
|
+
/** The credential a source yields. Every failure is fatal and named. */
|
|
90
|
+
export function readCredential(source, env) {
|
|
91
|
+
if (source.kind === "env") {
|
|
92
|
+
const token = readEnv(env, source.name);
|
|
93
|
+
if (token === null) {
|
|
94
|
+
throw new CredentialError(`${describeCredentialSource(source)} is unset or empty in this process`, `set ${source.name} for this process, or declare ${CREDENTIAL_FILE_ENV} instead`);
|
|
95
|
+
}
|
|
96
|
+
return token;
|
|
97
|
+
}
|
|
98
|
+
let contents;
|
|
99
|
+
try {
|
|
100
|
+
contents = readFileSync(source.path, "utf-8");
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, `create that file holding this dashboard's bearer token, or point ${CREDENTIAL_FILE_ENV} at one that exists`);
|
|
104
|
+
}
|
|
105
|
+
const token = contents.trim();
|
|
106
|
+
if (token === "") {
|
|
107
|
+
throw new CredentialError(`${describeCredentialSource(source)} is empty`, `write this dashboard's bearer token into that file`);
|
|
108
|
+
}
|
|
109
|
+
return token;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A credential's IDENTITY, for comparing two processes' credentials without
|
|
113
|
+
* either of them handling the other's. The first 12 hex of SHA-256 — 48 bits,
|
|
114
|
+
* enough that two distinct 48-character tokens colliding is not a thing that
|
|
115
|
+
* happens, and nothing an attacker can invert.
|
|
116
|
+
*/
|
|
117
|
+
export function credentialFingerprint(token) {
|
|
118
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
119
|
+
}
|
|
120
|
+
/** Declare, read, fingerprint — what the MCP server does at boot. */
|
|
121
|
+
export function resolveDeclaredCredential(env, home = homedir()) {
|
|
122
|
+
const source = declaredCredentialSource(env, home);
|
|
123
|
+
const token = readCredential(source, env);
|
|
124
|
+
return { source, token, fingerprint: credentialFingerprint(token) };
|
|
125
|
+
}
|
package/dist/entrypoint.js
CHANGED
|
File without changes
|
package/dist/entrypoint.test.js
CHANGED
|
File without changes
|
package/dist/handlers.js
CHANGED
|
File without changes
|
package/dist/http-client.js
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
@@ -69,7 +69,15 @@
|
|
|
69
69
|
*
|
|
70
70
|
* Env at boot (validated fail-loud — missing → process.exit(1)):
|
|
71
71
|
* DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
|
|
72
|
-
*
|
|
72
|
+
* the CREDENTIAL, declared exactly one of two ways and resolved by the ONE
|
|
73
|
+
* resolver this package's event bridge also uses (`credential.ts`, DX-2862):
|
|
74
|
+
* DANXBOT_DASHBOARD_TOKEN_FILE a file holding the bearer (an operator
|
|
75
|
+
* session, whose `.mcp.json` is git-tracked and so
|
|
76
|
+
* can never carry one); authoritative when set, and
|
|
77
|
+
* its failure is fatal — an inherited
|
|
78
|
+
* DANXBOT_DISPATCH_TOKEN is never consulted instead
|
|
79
|
+
* DANXBOT_DISPATCH_TOKEN the bearer verbatim (a dispatch, substituted into
|
|
80
|
+
* the materialized `.mcp.json` from its overlay)
|
|
73
81
|
* DANX_REPO_NAME repo half of the qualified board id
|
|
74
82
|
* DANXBOT_BOARD_NAME board-slug half of the qualified board id
|
|
75
83
|
* (the dispatch injects `board.slug` here);
|
|
@@ -86,6 +94,8 @@
|
|
|
86
94
|
*/
|
|
87
95
|
import { isEntrypointModule } from "./entrypoint.js";
|
|
88
96
|
import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
|
|
97
|
+
import { resolveDeclaredCredential } from "./credential.js";
|
|
98
|
+
import { recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
89
99
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
90
100
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
91
101
|
import { z } from "zod";
|
|
@@ -119,6 +129,13 @@ function readEnvOptional(name) {
|
|
|
119
129
|
// while a real run still fails loud on missing env via `boot()`.
|
|
120
130
|
let config;
|
|
121
131
|
let client;
|
|
132
|
+
/**
|
|
133
|
+
* DX-2862 — what this server would tell the session's plan event bridge about
|
|
134
|
+
* how to reach the dashboard AS THIS SERVER: the URL, where the credential came
|
|
135
|
+
* from, and the credential's fingerprint. Never the credential itself. Written
|
|
136
|
+
* to the session's connection record on a successful `plan_connect`.
|
|
137
|
+
*/
|
|
138
|
+
let connection;
|
|
122
139
|
/**
|
|
123
140
|
* Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
|
|
124
141
|
* halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
|
|
@@ -127,9 +144,10 @@ let client;
|
|
|
127
144
|
* Both halves are fail-loud required (Core Principle 1 — no fallback).
|
|
128
145
|
*/
|
|
129
146
|
function boot() {
|
|
147
|
+
const credential = resolveCredentialOrDie();
|
|
130
148
|
config = {
|
|
131
149
|
baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
|
|
132
|
-
token:
|
|
150
|
+
token: credential.token,
|
|
133
151
|
board: `${readEnvOrDie("DANX_REPO_NAME")}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
|
|
134
152
|
// DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
|
|
135
153
|
// outbound call so the agent's card writes chain under the launch; absent
|
|
@@ -138,8 +156,29 @@ function boot() {
|
|
|
138
156
|
// DX-2683 — the working session, when there is one. See below.
|
|
139
157
|
session: readSessionConfig(),
|
|
140
158
|
};
|
|
159
|
+
connection = {
|
|
160
|
+
dashboardUrl: config.baseUrl.replace(/\/+$/, ""),
|
|
161
|
+
source: credential.source,
|
|
162
|
+
fingerprint: credential.fingerprint,
|
|
163
|
+
};
|
|
141
164
|
client = new DashboardHttpClient(config);
|
|
142
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* DX-2862 — the credential, through the ONE resolver the `bridge` subcommand
|
|
168
|
+
* also uses, so a session's tools and its event stream can never sign in as two
|
|
169
|
+
* different identities. A credential that cannot be resolved exits non-zero
|
|
170
|
+
* naming the remedy, never a degraded or anonymous server.
|
|
171
|
+
*/
|
|
172
|
+
function resolveCredentialOrDie() {
|
|
173
|
+
try {
|
|
174
|
+
return resolveDeclaredCredential(process.env);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const credentialError = err;
|
|
178
|
+
console.error(`[danx-dashboard-mcp] ${credentialError.message}\n Fix: ${credentialError.fix}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
143
182
|
/**
|
|
144
183
|
* DX-2683 — WHO THIS SESSION IS, read from the environment Claude Code
|
|
145
184
|
* already provides.
|
|
@@ -708,7 +747,23 @@ server.tool("plan_connect",
|
|
|
708
747
|
.min(1)
|
|
709
748
|
.optional()
|
|
710
749
|
.describe("THIS session's own Claude session title — from `get_session({session_id:\"self\"}).title`, passed verbatim, never invented or derived from the repo/cwd. Stored on your session's row: a title different from what is already stored UPDATES it; omit to leave the stored title untouched. Not this plan's name. At most 200 characters — an overlong title is refused with a 400 naming its length."),
|
|
711
|
-
},
|
|
750
|
+
},
|
|
751
|
+
// DX-2862 — the connect is also the moment this session's event bridge learns
|
|
752
|
+
// WHICH dashboard credential owns its stream: of the dashboard MCP servers a
|
|
753
|
+
// session may have configured, the one that connected the plan is the one
|
|
754
|
+
// whose credential the stream must be minted with.
|
|
755
|
+
async (args) => {
|
|
756
|
+
const result = await planConnect(client, args);
|
|
757
|
+
return jsonResult({
|
|
758
|
+
...result,
|
|
759
|
+
...recordSessionConnectionAfterConnect(result, {
|
|
760
|
+
sessionId: config.session?.id,
|
|
761
|
+
dashboardUrl: connection.dashboardUrl,
|
|
762
|
+
source: connection.source,
|
|
763
|
+
fingerprint: connection.fingerprint,
|
|
764
|
+
}),
|
|
765
|
+
});
|
|
766
|
+
});
|
|
712
767
|
server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
|
|
713
768
|
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
714
769
|
body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
|
package/dist/listen.js
CHANGED
|
File without changes
|
package/dist/one-line.js
CHANGED
|
File without changes
|
package/dist/priority.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2862 — the record that hands ONE Claude Code session's dashboard
|
|
3
|
+
* connection from its MCP server to its event bridge.
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM THIS SOLVES. `danx-dashboard-mcp bridge` runs from a plugin hook,
|
|
6
|
+
* in the SESSION's environment — not in the MCP server's, which is the only
|
|
7
|
+
* place the server's `.mcp.json` env block exists. So the bridge cannot see
|
|
8
|
+
* what the server was configured with, and before this it simply used its own
|
|
9
|
+
* ambient `DANXBOT_DISPATCH_TOKEN`. When that was a different credential from
|
|
10
|
+
* the server's, every event was dropped silently (the card's incident).
|
|
11
|
+
*
|
|
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
|
|
14
|
+
* an env var — never a credential) and the credential's FINGERPRINT. The bridge
|
|
15
|
+
* reads that record, resolves the SAME source through the same resolver
|
|
16
|
+
* (`credential.ts`), and refuses to run unless what it resolved fingerprints
|
|
17
|
+
* identically. A rotated file or a session environment that disagrees with the
|
|
18
|
+
* server's therefore fails loud instead of streaming somebody else's events —
|
|
19
|
+
* or, as in DX-2862, none at all.
|
|
20
|
+
*
|
|
21
|
+
* WHY `plan_connect` AND NOTHING ELSE WRITES IT. A session can have several
|
|
22
|
+
* dashboard MCP servers configured (gpt-manager has a deployed one and a local
|
|
23
|
+
* one). The server that CONNECTED the session to a plan is, by construction,
|
|
24
|
+
* the one whose credential owns that session's stream — so the connect is the
|
|
25
|
+
* only moment at which the right server is identifiable. Writing at boot would
|
|
26
|
+
* be a race between servers over the same file.
|
|
27
|
+
*
|
|
28
|
+
* The record is per session, tiny, and pruned after `STALE_RECORD_MS` — the
|
|
29
|
+
* dashboard keeps only a week of events, so an older session cannot resume
|
|
30
|
+
* anything anyway.
|
|
31
|
+
*/
|
|
32
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
/** One canonical shape (CLAUDE.md Core Principle 1) — a record of any other version is refused, never adapted. */
|
|
36
|
+
export const SESSION_CONNECTION_SCHEMA_VERSION = 1;
|
|
37
|
+
export const STALE_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
38
|
+
export function sessionConnectionDir(home = homedir()) {
|
|
39
|
+
return join(home, ".config", "danxbot", "plan-sessions");
|
|
40
|
+
}
|
|
41
|
+
/** A session id is a path segment here, so an odd one is refused rather than turned into a path. */
|
|
42
|
+
export function sessionConnectionPath(sessionId, home = homedir()) {
|
|
43
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]+$/.test(sessionId)) {
|
|
44
|
+
throw new Error(`session id is missing or has unexpected characters: ${JSON.stringify(sessionId)}`);
|
|
45
|
+
}
|
|
46
|
+
return join(sessionConnectionDir(home), `${sessionId}.json`);
|
|
47
|
+
}
|
|
48
|
+
/** Remove records nothing has touched for `STALE_RECORD_MS`. */
|
|
49
|
+
export function pruneStaleConnections(dir, now) {
|
|
50
|
+
let names;
|
|
51
|
+
try {
|
|
52
|
+
names = readdirSync(dir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return; // nothing written yet
|
|
56
|
+
}
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (!name.endsWith(".json"))
|
|
59
|
+
continue;
|
|
60
|
+
const file = join(dir, name);
|
|
61
|
+
try {
|
|
62
|
+
if (now - statSync(file).mtimeMs > STALE_RECORD_MS)
|
|
63
|
+
rmSync(file, { force: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* removed by another process */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Record this session's connection. Write-then-rename, so a bridge reading
|
|
72
|
+
* concurrently sees the old record or the new one, never a torn one.
|
|
73
|
+
*/
|
|
74
|
+
export function writeSessionConnection(record, options = {}) {
|
|
75
|
+
const home = options.home ?? homedir();
|
|
76
|
+
const now = options.now ?? Date.now();
|
|
77
|
+
const file = sessionConnectionPath(record.sessionId, home);
|
|
78
|
+
const dir = sessionConnectionDir(home);
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
pruneStaleConnections(dir, now);
|
|
81
|
+
const full = {
|
|
82
|
+
schemaVersion: SESSION_CONNECTION_SCHEMA_VERSION,
|
|
83
|
+
...record,
|
|
84
|
+
connectedAt: new Date(now).toISOString(),
|
|
85
|
+
};
|
|
86
|
+
const tmp = `${file}.${process.pid}.${now}.tmp`;
|
|
87
|
+
writeFileSync(tmp, JSON.stringify(full), { mode: 0o600 });
|
|
88
|
+
renameSync(tmp, file);
|
|
89
|
+
return full;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* This session's record, or `null` when there is none (the session has never
|
|
93
|
+
* connected a plan from a server new enough to record one). A record that IS
|
|
94
|
+
* there but unreadable or of another shape throws — a connection we cannot
|
|
95
|
+
* understand is not the same thing as no connection, and quietly treating it as
|
|
96
|
+
* one is how a session goes deaf without a word.
|
|
97
|
+
*/
|
|
98
|
+
export function readSessionConnection(sessionId, options = {}) {
|
|
99
|
+
const file = sessionConnectionPath(sessionId, options.home ?? homedir());
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
raw = readFileSync(file, "utf-8");
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (err.code === "ENOENT")
|
|
106
|
+
return null;
|
|
107
|
+
throw new Error(`cannot read the session connection record ${file}: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
throw new Error(`the session connection record ${file} is not JSON: ${err.message}`);
|
|
115
|
+
}
|
|
116
|
+
const invalid = invalidRecordReason(parsed);
|
|
117
|
+
if (invalid !== null)
|
|
118
|
+
throw new Error(`the session connection record ${file} is unusable: ${invalid}`);
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Record the connection a successful `plan_connect` just made, and say so in
|
|
123
|
+
* that tool's own answer when it could not be recorded.
|
|
124
|
+
*
|
|
125
|
+
* FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
|
|
126
|
+
* missing produces a session that is connected and a bridge that refuses to
|
|
127
|
+
* start — so the failure belongs in the connect's answer, not only in a file
|
|
128
|
+
* nobody reads. It does NOT fail the connect itself: the session IS connected,
|
|
129
|
+
* and reporting otherwise would be a second lie on top of the first.
|
|
130
|
+
*/
|
|
131
|
+
export function recordSessionConnectionAfterConnect(result, inputs, write = writeSessionConnection) {
|
|
132
|
+
if (!result.ok)
|
|
133
|
+
return {};
|
|
134
|
+
if (inputs.sessionId === undefined || inputs.sessionId === "") {
|
|
135
|
+
return {
|
|
136
|
+
event_bridge_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so the plan event bridge has no connection to read — " +
|
|
137
|
+
"dashboard events will NOT reach this session",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
write({
|
|
142
|
+
sessionId: inputs.sessionId,
|
|
143
|
+
dashboardUrl: inputs.dashboardUrl,
|
|
144
|
+
source: inputs.source,
|
|
145
|
+
fingerprint: inputs.fingerprint,
|
|
146
|
+
});
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return {
|
|
151
|
+
event_bridge_error: `connected, but recording the connection for the plan event bridge failed (${err.message}) — ` +
|
|
152
|
+
"dashboard events will NOT reach this session until it succeeds",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Why a parsed record cannot be used, or `null` when it can. */
|
|
157
|
+
export function invalidRecordReason(value) {
|
|
158
|
+
if (typeof value !== "object" || value === null)
|
|
159
|
+
return "it is not an object";
|
|
160
|
+
const r = value;
|
|
161
|
+
if (r.schemaVersion !== SESSION_CONNECTION_SCHEMA_VERSION) {
|
|
162
|
+
return `schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION}`;
|
|
163
|
+
}
|
|
164
|
+
for (const field of ["sessionId", "dashboardUrl", "fingerprint", "connectedAt"]) {
|
|
165
|
+
if (typeof r[field] !== "string" || r[field] === "")
|
|
166
|
+
return `${field} is not a non-empty string`;
|
|
167
|
+
}
|
|
168
|
+
const source = r.source;
|
|
169
|
+
if (typeof source !== "object" || source === null)
|
|
170
|
+
return "source is not an object";
|
|
171
|
+
if (source.kind === "file")
|
|
172
|
+
return typeof source.path === "string" && source.path !== "" ? null : "source.path is not a path";
|
|
173
|
+
if (source.kind === "env")
|
|
174
|
+
return typeof source.name === "string" && source.name !== "" ? null : "source.name is not a variable name";
|
|
175
|
+
return `source.kind is ${JSON.stringify(source.kind)}, which this version does not know`;
|
|
176
|
+
}
|
package/dist/tool-defs.js
CHANGED
|
File without changes
|
package/dist/types.js
CHANGED
|
File without changes
|
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.76",
|
|
4
4
|
"description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|