@50arms/platform-mcp 0.0.0-stage → 0.1.437
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 +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +95 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4390 (PLAN-17 section 96) - how this server gets, holds and loses its dashboard credential.
|
|
3
|
+
*
|
|
4
|
+
* Three states, none of them a process exit:
|
|
5
|
+
*
|
|
6
|
+
* - `env` a dispatch declared `DANX_DASHBOARD_CREDENTIAL`; this never signs out.
|
|
7
|
+
* - `session-key` a user approved this session's access request; the key is stored per session
|
|
8
|
+
* (`session-key.ts`) and a 401 on it deletes the file (`dropKey`).
|
|
9
|
+
* - signed out no credential. Every tool but `plan_connect` halts; `plan_connect` asks.
|
|
10
|
+
*
|
|
11
|
+
* DX-4418: a fourth state, REVOKED, ends all three for good. The dashboard answers `401 key_revoked` to a key a person revoked
|
|
12
|
+
* (as opposed to one that lapsed); this object then remembers it for the life of the process, every dashboard tool (and
|
|
13
|
+
* `plan_connect`) returns the one stop halt (`key-revoked-halt.ts`), and no new access request is made. A session's key file is
|
|
14
|
+
* deleted; a dispatch's credential is left to its dispatch, but the halt is the same.
|
|
15
|
+
*
|
|
16
|
+
* ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `plan_connect` posts the request and
|
|
17
|
+
* answers at once with the approval URL and confirm code. From then on THIS object polls claim in
|
|
18
|
+
* the background, so the key is stored the moment the user approves, and the next `plan_connect`
|
|
19
|
+
* waits at most `awaitMs` (about 45s) for it before answering `approval_pending`. The secret the
|
|
20
|
+
* poll needs lives only in this process's memory; a restarted session asks again.
|
|
21
|
+
*
|
|
22
|
+
* DX-4530: a request has no deadline. It stays open for as long as this process keeps polling it, so it waits for a person
|
|
23
|
+
* however long they take, and it expires once the session ends (its polls stop). A poll that cannot reach the dashboard is
|
|
24
|
+
* retried; the dashboard answers `expired` if they stopped arriving for longer than its requester-gone window.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { hostname } from "node:os";
|
|
28
|
+
import { claimAccessRequest, createAccessRequest } from "./access-request.js";
|
|
29
|
+
import { credentialFingerprint } from "./credential.js";
|
|
30
|
+
import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
|
|
31
|
+
import { deleteSessionKey, writeSessionKey } from "./session-key.js";
|
|
32
|
+
import { McpOutdatedError } from "./version-floor.js";
|
|
33
|
+
/** Longest a `plan_connect` call waits for an approval already asked for. */
|
|
34
|
+
export const AWAIT_APPROVAL_MS = 45_000;
|
|
35
|
+
/** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
|
|
36
|
+
const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
|
|
37
|
+
/** DX-5882: longest dashboard answer body copied into a drop's log line. */
|
|
38
|
+
const REFUSAL_LOG_LIMIT = 300;
|
|
39
|
+
/** The request's title when the agent gave none: the session id's first characters. */
|
|
40
|
+
const TITLE_FALLBACK_LENGTH = 8;
|
|
41
|
+
export const SIGN_IN_HALT = "Not signed in to the 50 Arms dashboard. Call `plan_connect` (with `plan_id`) to request access; the user approves it in their browser, then this tool works.";
|
|
42
|
+
/** DX-4418: the session's key lapsed (a day unused) or the dashboard does not know it; a PERSON's revoke is `keyRevokedHalt` instead. */
|
|
43
|
+
export const KEY_LAPSED_HALT = "The dashboard no longer accepts this session's key (it lapsed after a day unused), so this session is signed out. " +
|
|
44
|
+
SIGN_IN_HALT;
|
|
45
|
+
const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
|
|
46
|
+
"matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval. " +
|
|
47
|
+
"The request stays open until this session ends, however long the user takes.";
|
|
48
|
+
const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve; the request stays open until this session ends. Remind them of the " +
|
|
49
|
+
"confirm code and the URL, then call `plan_connect` again.";
|
|
50
|
+
const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
|
|
51
|
+
const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
|
|
52
|
+
"it must be updated before this session can sign in";
|
|
53
|
+
const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
|
|
54
|
+
const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (your shell, or a repo's committed .claude/settings.json env block) and restart the session`;
|
|
55
|
+
const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
|
|
56
|
+
const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
|
|
57
|
+
"start a new request";
|
|
58
|
+
const EXPIRED_NOTE = "The previous request expired before it was approved: the dashboard stopped hearing from this session. This is a new " +
|
|
59
|
+
"request: show the user its new confirm code and URL.";
|
|
60
|
+
export const defaultSleep = (ms) => new Promise((resolve) => {
|
|
61
|
+
setTimeout(resolve, ms).unref();
|
|
62
|
+
});
|
|
63
|
+
const notReady = (answer, isError, halt) => ({
|
|
64
|
+
signedIn: false,
|
|
65
|
+
answer,
|
|
66
|
+
isError,
|
|
67
|
+
...(halt === undefined ? {} : { halt }),
|
|
68
|
+
});
|
|
69
|
+
export class SessionAccess {
|
|
70
|
+
deps;
|
|
71
|
+
credential;
|
|
72
|
+
// DX-4418: set once the dashboard says a person revoked the key; never cleared (a restarted server is a new process).
|
|
73
|
+
revoked = null;
|
|
74
|
+
pending = null;
|
|
75
|
+
// DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
|
|
76
|
+
ensureQueue = Promise.resolve();
|
|
77
|
+
lastKeyFileWrite = 0;
|
|
78
|
+
// DX-5882: why this server holds no credential (null while it holds one); logged with every "Not signed in" halt.
|
|
79
|
+
signedOutCause = null;
|
|
80
|
+
fetchImpl;
|
|
81
|
+
now;
|
|
82
|
+
sleep;
|
|
83
|
+
awaitMs;
|
|
84
|
+
constructor(deps) {
|
|
85
|
+
this.deps = deps;
|
|
86
|
+
this.credential = deps.initial;
|
|
87
|
+
this.fetchImpl = deps.fetch ?? fetch;
|
|
88
|
+
this.now = deps.now ?? Date.now;
|
|
89
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
90
|
+
this.awaitMs = deps.awaitMs ?? AWAIT_APPROVAL_MS;
|
|
91
|
+
deps.setToken(deps.initial?.token ?? "");
|
|
92
|
+
if (deps.initial === null) {
|
|
93
|
+
this.signedOutCause = `booted with no credential: no DANX_DASHBOARD_CREDENTIAL, no stored key for session ${deps.sessionId ?? "(none: CLAUDE_CODE_SESSION_ID is unset)"}`;
|
|
94
|
+
deps.log?.(`[platform-mcp] signed out: ${this.signedOutCause}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
isSignedIn() {
|
|
98
|
+
return this.credential !== null && this.revoked === null;
|
|
99
|
+
}
|
|
100
|
+
/** DX-4523: the key this session holds now, or "" when it holds none (the permission stream opens with it). */
|
|
101
|
+
currentToken() {
|
|
102
|
+
return this.credential === null || this.revoked !== null ? "" : this.credential.token;
|
|
103
|
+
}
|
|
104
|
+
/** DX-4418: the stop halt once a person revoked the key, else null. */
|
|
105
|
+
revokedHalt() {
|
|
106
|
+
return this.revoked === null ? null : keyRevokedHalt(this.revoked.revocation, this.revoked.dispatched);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* DX-4418: a dashboard answer that says a person revoked the key behind it: remembered for good (a session's key file is
|
|
110
|
+
* deleted, a dispatch's credential is left to its dispatch) and answered as the stop halt. Null for every other answer.
|
|
111
|
+
*/
|
|
112
|
+
refusalHalt(result) {
|
|
113
|
+
const revocation = keyRevocationOf(result);
|
|
114
|
+
if (revocation === null)
|
|
115
|
+
return null;
|
|
116
|
+
this.revoked ??= { revocation, dispatched: this.credential?.source.kind === "env" };
|
|
117
|
+
this.dropKey(result);
|
|
118
|
+
return this.revokedHalt();
|
|
119
|
+
}
|
|
120
|
+
/** Where the current credential comes from and its fingerprint, for the relay's connection record. */
|
|
121
|
+
connection() {
|
|
122
|
+
if (this.credential === null || this.revoked !== null)
|
|
123
|
+
return null;
|
|
124
|
+
return {
|
|
125
|
+
dashboardUrl: this.deps.dashboardUrl.replace(/\/+$/, ""),
|
|
126
|
+
source: this.credential.source,
|
|
127
|
+
fingerprint: this.credential.fingerprint,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The dashboard answered 401 to this session's key: drop it. A dispatch credential is never
|
|
132
|
+
* dropped (the dispatch owns it). True when this signed the session out. DX-5882: `refusal` is the dashboard answer that
|
|
133
|
+
* caused it; one stderr line names the key's fingerprint, its file and that answer, and later halts repeat it.
|
|
134
|
+
*/
|
|
135
|
+
dropKey(refusal) {
|
|
136
|
+
if (this.credential === null || this.credential.source.kind !== "session-key")
|
|
137
|
+
return false;
|
|
138
|
+
const { fingerprint, source } = this.credential;
|
|
139
|
+
this.signedOutCause = `dropped the session key ${fingerprint} (${source.path}): the dashboard answered ${refusal.status} ${JSON.stringify(refusal.body).slice(0, REFUSAL_LOG_LIMIT)}`;
|
|
140
|
+
this.deps.log?.(`[platform-mcp] ${this.signedOutCause}`);
|
|
141
|
+
deleteSessionKey(source.path);
|
|
142
|
+
this.credential = null;
|
|
143
|
+
this.pending = null; // DX-4390: a settled request of the dropped key must not read as "expired"
|
|
144
|
+
this.deps.setToken("");
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Run a dashboard call for a tool: halted while signed out, and signed out (key file deleted)
|
|
149
|
+
* when the dashboard answers 401 to this session's key.
|
|
150
|
+
*/
|
|
151
|
+
async guard(run) {
|
|
152
|
+
const revokedHalt = this.revokedHalt();
|
|
153
|
+
if (revokedHalt !== null)
|
|
154
|
+
return { halt: revokedHalt };
|
|
155
|
+
if (this.credential === null) {
|
|
156
|
+
this.deps.log?.(`[platform-mcp] halted a dashboard tool call with "Not signed in": ${this.signedOutCause}`);
|
|
157
|
+
return { halt: SIGN_IN_HALT };
|
|
158
|
+
}
|
|
159
|
+
const result = await run();
|
|
160
|
+
const refused = this.refusalHalt(result);
|
|
161
|
+
if (refused !== null)
|
|
162
|
+
return { halt: refused };
|
|
163
|
+
if (result.status === 401 && this.dropKey(result))
|
|
164
|
+
return { halt: KEY_LAPSED_HALT };
|
|
165
|
+
this.keepKeyFile();
|
|
166
|
+
return { result };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* DX-4390: the stored file is the relay's only way to the key, and the 7-day prune ages out
|
|
170
|
+
* a file by when it was last written. A server in use rewrites it (atomically) at most hourly,
|
|
171
|
+
* which also restores a file someone deleted while this process still holds the key.
|
|
172
|
+
*/
|
|
173
|
+
keepKeyFile() {
|
|
174
|
+
const credential = this.credential;
|
|
175
|
+
if (credential === null || credential.source.kind !== "session-key")
|
|
176
|
+
return;
|
|
177
|
+
const now = this.now();
|
|
178
|
+
if (now - this.lastKeyFileWrite < KEEP_KEY_FILE_EVERY_MS && existsSync(credential.source.path))
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey: credential.token }, { home: this.deps.home, now });
|
|
182
|
+
this.lastKeyFileWrite = now;
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
this.deps.log?.(`[platform-mcp] could not refresh the session key file: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Make sure this session holds a key. Signed in: `{signedIn: true}`. Otherwise the answer
|
|
190
|
+
* `plan_connect` returns: the request just made, the one still pending (after waiting up to
|
|
191
|
+
* `awaitMs` for it), or why there is none. Calls run one at a time.
|
|
192
|
+
*/
|
|
193
|
+
ensureAccess(title) {
|
|
194
|
+
const run = this.ensureQueue.then(() => this.ensureAccessNow(title));
|
|
195
|
+
this.ensureQueue = run.catch(() => undefined);
|
|
196
|
+
return run;
|
|
197
|
+
}
|
|
198
|
+
async ensureAccessNow(title) {
|
|
199
|
+
// DX-4418: a person revoked this session's access; it never asks again.
|
|
200
|
+
const revokedHalt = this.revokedHalt();
|
|
201
|
+
if (revokedHalt !== null)
|
|
202
|
+
return notReady({ state: "access_revoked", instruction: revokedHalt }, true, revokedHalt);
|
|
203
|
+
if (this.credential !== null)
|
|
204
|
+
return { signedIn: true };
|
|
205
|
+
const sessionId = this.deps.sessionId;
|
|
206
|
+
if (sessionId === undefined || sessionId === "") {
|
|
207
|
+
return notReady({ state: "no_session_id", error: NO_SESSION_ID_ERROR }, true);
|
|
208
|
+
}
|
|
209
|
+
let note;
|
|
210
|
+
if (this.pending !== null) {
|
|
211
|
+
const pending = this.pending;
|
|
212
|
+
const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
|
|
213
|
+
if (outcome === null)
|
|
214
|
+
return notReady(this.requestAnswer("approval_pending", pending.request), false);
|
|
215
|
+
this.pending = null;
|
|
216
|
+
switch (outcome.kind) {
|
|
217
|
+
case "approved":
|
|
218
|
+
return { signedIn: true };
|
|
219
|
+
case "denied":
|
|
220
|
+
return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
|
|
221
|
+
case "failed":
|
|
222
|
+
return notReady({ state: "request_failed", error: outcome.error }, true);
|
|
223
|
+
case "expired":
|
|
224
|
+
note = EXPIRED_NOTE;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return this.createRequest(sessionId, title, note);
|
|
228
|
+
}
|
|
229
|
+
async createRequest(sessionId, title, note) {
|
|
230
|
+
const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
|
|
231
|
+
kind: "session",
|
|
232
|
+
labelId: sessionId,
|
|
233
|
+
title: title ?? sessionId.slice(0, TITLE_FALLBACK_LENGTH),
|
|
234
|
+
machine: this.deps.machine ?? hostname(),
|
|
235
|
+
clientVersion: this.deps.clientVersion,
|
|
236
|
+
...(this.deps.team === undefined ? {} : { team: this.deps.team }),
|
|
237
|
+
});
|
|
238
|
+
switch (created.kind) {
|
|
239
|
+
case "dashboard_outdated":
|
|
240
|
+
return notReady({ state: "dashboard_outdated", error: DASHBOARD_OUTDATED_ERROR }, true);
|
|
241
|
+
case "unknown_team":
|
|
242
|
+
return notReady({ state: "unknown_team", team: created.team, error: unknownTeamError(created.team) }, true);
|
|
243
|
+
case "rate_limited":
|
|
244
|
+
return notReady({ state: "rate_limited", retryAfterSeconds: created.retryAfterSeconds, error: RATE_LIMITED_ERROR }, true);
|
|
245
|
+
case "refused":
|
|
246
|
+
return notReady({ state: "request_refused", status: created.status, body: created.body }, true);
|
|
247
|
+
case "created":
|
|
248
|
+
this.pending = { request: created.request, settled: this.pollUntilSettled(created.request) };
|
|
249
|
+
return notReady({ ...this.requestAnswer("approval_required", created.request), ...(note ? { note } : {}) }, false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
requestAnswer(state, request) {
|
|
253
|
+
return {
|
|
254
|
+
state,
|
|
255
|
+
approvalUrl: request.approvalUrl,
|
|
256
|
+
confirmCode: request.confirmCode,
|
|
257
|
+
instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// DX-4530: no give-up time - the polls are what keep the request open, until the dashboard answers how it ended.
|
|
261
|
+
async pollUntilSettled(request) {
|
|
262
|
+
let waitMs = request.pollMs;
|
|
263
|
+
for (;;) {
|
|
264
|
+
await this.sleep(waitMs);
|
|
265
|
+
waitMs = request.pollMs;
|
|
266
|
+
let claim;
|
|
267
|
+
try {
|
|
268
|
+
claim = await claimAccessRequest(this.fetchImpl, this.deps.dashboardUrl, request.secret);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// A claim the dashboard could not answer is retried until it answers how the request ended; an outdated
|
|
272
|
+
// MCP will never get a different answer, so that one ends it.
|
|
273
|
+
if (err instanceof McpOutdatedError)
|
|
274
|
+
return { kind: "failed", error: err.message };
|
|
275
|
+
this.deps.log?.(`[platform-mcp] access request poll failed, retrying: ${err.message}`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
switch (claim.kind) {
|
|
279
|
+
case "pending":
|
|
280
|
+
continue;
|
|
281
|
+
case "rate_limited":
|
|
282
|
+
waitMs = Math.max(request.pollMs, claim.retryAfterSeconds * 1000);
|
|
283
|
+
continue;
|
|
284
|
+
case "approved":
|
|
285
|
+
return this.storeKey(claim.apiKey);
|
|
286
|
+
case "denied":
|
|
287
|
+
return { kind: "denied" };
|
|
288
|
+
case "expired":
|
|
289
|
+
case "not_found":
|
|
290
|
+
return { kind: "expired" };
|
|
291
|
+
case "claimed":
|
|
292
|
+
return { kind: "failed", error: CLAIMED_LOST_ERROR };
|
|
293
|
+
case "refused":
|
|
294
|
+
return {
|
|
295
|
+
kind: "failed",
|
|
296
|
+
error: `the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
storeKey(apiKey) {
|
|
302
|
+
const now = this.now();
|
|
303
|
+
try {
|
|
304
|
+
const path = writeSessionKey({ dashboardUrl: this.deps.dashboardUrl, sessionId: this.deps.sessionId, apiKey }, { home: this.deps.home, now });
|
|
305
|
+
this.credential = {
|
|
306
|
+
source: { kind: "session-key", path },
|
|
307
|
+
token: apiKey,
|
|
308
|
+
fingerprint: credentialFingerprint(apiKey),
|
|
309
|
+
};
|
|
310
|
+
this.lastKeyFileWrite = now;
|
|
311
|
+
this.signedOutCause = null;
|
|
312
|
+
this.deps.setToken(apiKey);
|
|
313
|
+
return { kind: "approved" };
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
return {
|
|
317
|
+
kind: "failed",
|
|
318
|
+
error: `the request was approved but storing the key failed: ${err.message}; start a new request`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2862 — the record that hands ONE Claude Code session's dashboard
|
|
3
|
+
* connection from its MCP server to its other readers (`restart_notice`, the listener reminder).
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM THIS SOLVES. A reader outside the server process runs in the SESSION's environment, not in the MCP server's, which is the only
|
|
6
|
+
* place the server's `.mcp.json` env block exists. So it cannot see what the server was configured with, and before this it
|
|
7
|
+
* simply used its own ambient env credential. When that was a different credential from the server's, its report was
|
|
8
|
+
* dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
|
|
9
|
+
* server, with the server's own credential. The listener reminder reads this record too.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
|
|
12
|
+
* writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
|
|
13
|
+
* an env var — never a credential) and the credential's FINGERPRINT. A reader
|
|
14
|
+
* (`restart_notice`) reads that record, resolves the SAME source through the same resolver
|
|
15
|
+
* (`credential.ts`), and refuses to run unless what it resolved fingerprints
|
|
16
|
+
* identically. A rotated file or a session environment that disagrees with the
|
|
17
|
+
* server's therefore fails loud instead of reporting as somebody else.
|
|
18
|
+
*
|
|
19
|
+
* WHY `plan_connect` AND NOTHING ELSE WRITES IT. A session can have several
|
|
20
|
+
* dashboard MCP servers configured (gpt-manager has a deployed one and a local
|
|
21
|
+
* one). The server that CONNECTED the session to a plan is, by construction,
|
|
22
|
+
* the one whose credential owns that session's stream — so the connect is the
|
|
23
|
+
* only moment at which the right server is identifiable. Writing at boot would
|
|
24
|
+
* be a race between servers over the same file.
|
|
25
|
+
*
|
|
26
|
+
* The record is per session, tiny, and pruned after `STALE_RECORD_MS` — the
|
|
27
|
+
* dashboard keeps only a week of events, so an older session cannot resume
|
|
28
|
+
* anything anyway.
|
|
29
|
+
*/
|
|
30
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
31
|
+
import { writeFileDurable } from "./durable-write.js";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join, resolve, win32 } from "node:path";
|
|
34
|
+
/** One canonical shape (CLAUDE.md Core Principle 1) — a record of any other version is refused, never adapted. */
|
|
35
|
+
// DX-3928 — v2 adds `projectKey`, the directory the connecting session ran in, so a
|
|
36
|
+
// restarted session (a NEW session id, no record of its own) can find which plan the
|
|
37
|
+
// previous session of the same project was on (`findPredecessorCandidates`).
|
|
38
|
+
export const SESSION_CONNECTION_SCHEMA_VERSION = 3;
|
|
39
|
+
// DX-4390 — v3: the credential source is `session-key` (an approved per-session key file) or `env`;
|
|
40
|
+
// the v2 `file` source (a hand-placed token file) no longer exists.
|
|
41
|
+
export const STALE_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
42
|
+
/**
|
|
43
|
+
* DX-3928 — the one definition of "the same project": the resolved working
|
|
44
|
+
* directory, normalised so the SAME directory reached two ways compares equal.
|
|
45
|
+
* A Windows path is written with forward slashes and a lower-case drive letter
|
|
46
|
+
* (`C:\Work\p` and `c:/Work/p` are one key), because the MCP server and the
|
|
47
|
+
* plugin's hook can each report the cwd in either spelling.
|
|
48
|
+
*/
|
|
49
|
+
export function projectKeyOf(cwd) {
|
|
50
|
+
if (!/^[A-Za-z]:[\\/]/.test(cwd))
|
|
51
|
+
return resolve(cwd);
|
|
52
|
+
const resolved = win32.resolve(cwd).replace(/\\/g, "/");
|
|
53
|
+
return resolved[0].toLowerCase() + resolved.slice(1);
|
|
54
|
+
}
|
|
55
|
+
export function sessionConnectionDir(home = homedir()) {
|
|
56
|
+
return join(home, ".config", "danxbot", "plan-sessions");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A session id is a path segment (here and in `session-key.ts`), so an odd one is refused rather than
|
|
60
|
+
* turned into a path.
|
|
61
|
+
*/
|
|
62
|
+
export function assertSessionIdSegment(sessionId) {
|
|
63
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]+$/.test(sessionId)) {
|
|
64
|
+
throw new Error(`session id is missing or has unexpected characters: ${JSON.stringify(sessionId)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function sessionConnectionPath(sessionId, home = homedir()) {
|
|
68
|
+
assertSessionIdSegment(sessionId);
|
|
69
|
+
return join(sessionConnectionDir(home), `${sessionId}.json`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Remove the per-session `.json` files in `dir` that nothing has touched for `STALE_RECORD_MS`, and the `.tmp` files a failed
|
|
73
|
+
* write-then-rename left beside them (DX-4993).
|
|
74
|
+
*/
|
|
75
|
+
export function pruneStaleSessionFiles(dir, now) {
|
|
76
|
+
let names;
|
|
77
|
+
try {
|
|
78
|
+
names = readdirSync(dir);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return; // nothing written yet
|
|
82
|
+
}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
if (!name.endsWith(".json") && !name.endsWith(".tmp"))
|
|
85
|
+
continue;
|
|
86
|
+
const file = join(dir, name);
|
|
87
|
+
try {
|
|
88
|
+
if (now - statSync(file).mtimeMs > STALE_RECORD_MS)
|
|
89
|
+
rmSync(file, { force: true });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* removed by another process */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Record this session's connection. Write-then-rename, so a reader
|
|
98
|
+
* concurrently sees the old record or the new one, never a torn one.
|
|
99
|
+
*/
|
|
100
|
+
export function writeSessionConnection(record, options = {}) {
|
|
101
|
+
const home = options.home ?? homedir();
|
|
102
|
+
const now = options.now ?? Date.now();
|
|
103
|
+
const file = sessionConnectionPath(record.sessionId, home);
|
|
104
|
+
const dir = sessionConnectionDir(home);
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
pruneStaleSessionFiles(dir, now);
|
|
107
|
+
const full = {
|
|
108
|
+
schemaVersion: SESSION_CONNECTION_SCHEMA_VERSION,
|
|
109
|
+
...record,
|
|
110
|
+
connectedAt: new Date(now).toISOString(),
|
|
111
|
+
};
|
|
112
|
+
writeFileDurable(file, JSON.stringify(full), { mode: 0o600 });
|
|
113
|
+
return full;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* This session's record, or `null` when there is none (the session has never
|
|
117
|
+
* connected a plan from a server new enough to record one). A record that IS
|
|
118
|
+
* there but unreadable or of another shape throws — a connection we cannot
|
|
119
|
+
* understand is not the same thing as no connection, and quietly treating it as
|
|
120
|
+
* one is how a session goes deaf without a word.
|
|
121
|
+
*/
|
|
122
|
+
export function readSessionConnection(sessionId, options = {}) {
|
|
123
|
+
const file = sessionConnectionPath(sessionId, options.home ?? homedir());
|
|
124
|
+
let raw;
|
|
125
|
+
try {
|
|
126
|
+
raw = readFileSync(file, "utf-8");
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err.code === "ENOENT")
|
|
130
|
+
return null;
|
|
131
|
+
throw new Error(`cannot read the session connection record ${file}: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw new Error(`the session connection record ${file} is not JSON: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
const invalid = invalidRecordReason(parsed);
|
|
141
|
+
if (invalid !== null)
|
|
142
|
+
throw new Error(`the session connection record ${file} is unusable: ${invalid}`);
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Record the connection a successful `plan_connect` just made, and say so in
|
|
147
|
+
* that tool's own answer when it could not be recorded.
|
|
148
|
+
*
|
|
149
|
+
* FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
|
|
150
|
+
* missing produces a session that is connected but whose `restart_notice` lookup and listener reminder cannot
|
|
151
|
+
* find its connection — so the failure belongs in the connect's answer, not only in a file
|
|
152
|
+
* nobody reads. It does NOT fail the connect itself: the session IS connected,
|
|
153
|
+
* and reporting otherwise would be a second lie on top of the first.
|
|
154
|
+
*/
|
|
155
|
+
export function recordSessionConnectionAfterConnect(result, inputs, write = writeSessionConnection) {
|
|
156
|
+
if (!result.ok)
|
|
157
|
+
return {};
|
|
158
|
+
if (inputs.sessionId === undefined || inputs.sessionId === "") {
|
|
159
|
+
return {
|
|
160
|
+
connection_record_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so no connection record can be written — " +
|
|
161
|
+
"`restart_notice` and the listener reminder will not find this session's connection",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
write({
|
|
166
|
+
sessionId: inputs.sessionId,
|
|
167
|
+
projectKey: inputs.projectKey,
|
|
168
|
+
dashboardUrl: inputs.dashboardUrl,
|
|
169
|
+
source: inputs.source,
|
|
170
|
+
fingerprint: inputs.fingerprint,
|
|
171
|
+
});
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
return {
|
|
176
|
+
connection_record_error: `connected, but recording this session's connection failed (${err.message}) — ` +
|
|
177
|
+
"`restart_notice` and the listener reminder will not find this session's connection until it succeeds",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* DX-6556 — a manual pickup, which goes through the generic `50arms_api`, can connect the session as `card_connect` does; the dashboard marks that answer, and it is then
|
|
183
|
+
* handled as a connect, so no record or tool sync is missed.
|
|
184
|
+
*/
|
|
185
|
+
export function isPickupConnect(result) {
|
|
186
|
+
return result.ok && typeof result.body === "object" && result.body !== null && result.body.connectedByPickup === true;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* DX-2985 — drop this session's local connection record after a successful leave (`plan_connect {disconnect: true}`), so
|
|
190
|
+
* nothing on this machine still treats the session as plan-connected (the listener-reminder reads this record). A refused
|
|
191
|
+
* leave keeps it: the session is still on its plan. Never throws for an absent record.
|
|
192
|
+
*/
|
|
193
|
+
export function removeSessionConnectionAfterLeave(result, sessionId, home = homedir()) {
|
|
194
|
+
if (!result.ok || sessionId === undefined || sessionId === "")
|
|
195
|
+
return;
|
|
196
|
+
rmSync(sessionConnectionPath(sessionId, home), { force: true });
|
|
197
|
+
}
|
|
198
|
+
/** Why a parsed record cannot be used, or `null` when it can. */
|
|
199
|
+
export function invalidRecordReason(value) {
|
|
200
|
+
if (typeof value !== "object" || value === null)
|
|
201
|
+
return "it is not an object";
|
|
202
|
+
const r = value;
|
|
203
|
+
if (r.schemaVersion !== SESSION_CONNECTION_SCHEMA_VERSION) {
|
|
204
|
+
return (`schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION} — ` +
|
|
205
|
+
"a session connected by an older MCP server wrote it; call plan_connect again to rewrite it");
|
|
206
|
+
}
|
|
207
|
+
for (const field of ["sessionId", "projectKey", "dashboardUrl", "fingerprint", "connectedAt"]) {
|
|
208
|
+
if (typeof r[field] !== "string" || r[field] === "")
|
|
209
|
+
return `${field} is not a non-empty string`;
|
|
210
|
+
}
|
|
211
|
+
const source = r.source;
|
|
212
|
+
if (typeof source !== "object" || source === null)
|
|
213
|
+
return "source is not an object";
|
|
214
|
+
if (source.kind === "session-key")
|
|
215
|
+
return typeof source.path === "string" && source.path !== "" ? null : "source.path is not a path";
|
|
216
|
+
if (source.kind === "env")
|
|
217
|
+
return typeof source.name === "string" && source.name !== "" ? null : "source.name is not a variable name";
|
|
218
|
+
return `source.kind is ${JSON.stringify(source.kind)}, which this version does not know`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* DX-3928 — the records of the sessions that ran in `projectKey` before
|
|
222
|
+
* `currentSessionId`, NEWEST FIRST: other sessions' records for that project,
|
|
223
|
+
* within `STALE_RECORD_MS`. Which of them is a real predecessor (not a live
|
|
224
|
+
* sibling in the same checkout) is the dashboard's call, not this reader's, so
|
|
225
|
+
* all candidates are returned; the caller asks about them in order.
|
|
226
|
+
*
|
|
227
|
+
* A record that cannot be understood (another schema version, not JSON) is
|
|
228
|
+
* SKIPPED here and nowhere else: this answers "was a plan being worked in this
|
|
229
|
+
* project", and a record from an older server cannot say. Every consumer of a
|
|
230
|
+
* session's OWN record (`readSessionConnection`) still fails loud.
|
|
231
|
+
*/
|
|
232
|
+
export function findPredecessorCandidates(projectKey, currentSessionId, options = {}) {
|
|
233
|
+
const dir = sessionConnectionDir(options.home ?? homedir());
|
|
234
|
+
const now = options.now ?? Date.now();
|
|
235
|
+
let names;
|
|
236
|
+
try {
|
|
237
|
+
names = readdirSync(dir);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return []; // nothing written yet
|
|
241
|
+
}
|
|
242
|
+
const found = [];
|
|
243
|
+
for (const name of names) {
|
|
244
|
+
if (!name.endsWith(".json"))
|
|
245
|
+
continue;
|
|
246
|
+
let parsed;
|
|
247
|
+
try {
|
|
248
|
+
parsed = JSON.parse(readFileSync(join(dir, name), "utf-8"));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (invalidRecordReason(parsed) !== null)
|
|
254
|
+
continue;
|
|
255
|
+
const record = parsed;
|
|
256
|
+
if (record.projectKey !== projectKey || record.sessionId === currentSessionId)
|
|
257
|
+
continue;
|
|
258
|
+
const connectedAt = Date.parse(record.connectedAt);
|
|
259
|
+
if (!Number.isFinite(connectedAt) || now - connectedAt > STALE_RECORD_MS)
|
|
260
|
+
continue;
|
|
261
|
+
found.push({ record, connectedAt });
|
|
262
|
+
}
|
|
263
|
+
return found.sort((x, y) => y.connectedAt - x.connectedAt).map((f) => f.record);
|
|
264
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-6692 - the ONE local store of a per-session control file the relay keeps for the plugin: the compaction control (`compaction-control.ts`, DX-4993) and the clear release
|
|
3
|
+
* (`clear-control.ts`). Each is `~/.config/danxbot/<dirName>/<sessionId>.json`, written atomically (a sibling then a rename: the plugin must never read half a file), read back through the
|
|
4
|
+
* kind's own parser, and pruned: every write removes the files nothing has touched for a week (`pruneStaleSessionFiles`). A live relay touches its session's files at every heartbeat, so only
|
|
5
|
+
* the files of sessions whose relay has gone are removed.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, readFileSync, utimesSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { writeFileDurable } from "./durable-write.js";
|
|
11
|
+
import { assertSessionIdSegment, pruneStaleSessionFiles } from "./session-connection.js";
|
|
12
|
+
/** `~/.config/danxbot/<dirName>`, next to the plugin's other per-session records. */
|
|
13
|
+
export function sessionControlDir(dirName, home = homedir()) {
|
|
14
|
+
return join(home, ".config", "danxbot", dirName);
|
|
15
|
+
}
|
|
16
|
+
/** `~/.config/danxbot/<dirName>/<sessionId>.json`; refuses a session id that is not a plain id. */
|
|
17
|
+
export function sessionControlFilePath(dirName, sessionId, home = homedir()) {
|
|
18
|
+
assertSessionIdSegment(sessionId);
|
|
19
|
+
return join(sessionControlDir(dirName, home), `${sessionId}.json`);
|
|
20
|
+
}
|
|
21
|
+
/** The real store of `dirName`'s files under `home`. `parse` reads one file (naming `where` when it refuses it); a file written for another session is refused. */
|
|
22
|
+
export function sessionControlFilesIn(dirName, parse, home = homedir(), now = Date.now) {
|
|
23
|
+
return {
|
|
24
|
+
read(sessionId) {
|
|
25
|
+
const path = sessionControlFilePath(dirName, sessionId, home);
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = readFileSync(path, "utf-8");
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === "ENOENT")
|
|
32
|
+
return null;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
const file = parse(text, path);
|
|
36
|
+
if (file.sessionId !== sessionId)
|
|
37
|
+
throw new Error(`${path} was written for session ${JSON.stringify(file.sessionId)}`);
|
|
38
|
+
return file;
|
|
39
|
+
},
|
|
40
|
+
write(sessionId, file) {
|
|
41
|
+
const path = sessionControlFilePath(dirName, sessionId, home);
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
pruneStaleSessionFiles(dirname(path), now());
|
|
44
|
+
writeFileDurable(path, JSON.stringify(file));
|
|
45
|
+
},
|
|
46
|
+
touch(sessionId) {
|
|
47
|
+
const at = new Date(now());
|
|
48
|
+
try {
|
|
49
|
+
utimesSync(sessionControlFilePath(dirName, sessionId, home), at, at);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
// a session with no file has nothing to keep; any other failure is the caller's to report
|
|
53
|
+
if (err.code !== "ENOENT")
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|