@50arms/platform-mcp 0.1.442 → 0.1.443

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from boa
18
18
  ## Signing in (DX-4390): request and approve
19
19
 
20
20
  An operator session has no stored credential. Signed out, `plan_connect` (optionally with `title`, the session's own Claude title) posts `POST /api/access-requests` and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`: show the user the code and open the URL; they approve in the dashboard. The server polls `POST /api/access-requests/claim` in the background, so the key is stored when the approval lands; the next `plan_connect` waits up to about 45 seconds for it, then connects, or answers `approval_pending`. One tool call never blocks for minutes. `denied` is reported once; a request that lapsed (the dashboard deletes it within about a minute of expiry, so a claim answers 404) starts a fresh one on the next call.
21
+ - **A machine secret and an auto-approved request (DX-6764, PLAN-128).** Every `session` access request also sends `machineSecret`: one random secret per machine and per dashboard, made the first time it is needed in `~/.config/danxbot/machine-secrets/<sha12(dashboard url)>.json` (mode 0600, written durably, then hard-linked into place so two sessions starting at once on a fresh machine end up with the same one; an unusable file is a loud error, never replaced). The dashboard keeps only its hash, so a person can let later sessions from this machine sign in by themselves; it is never sent to another dashboard, and the `machine` daemon's request and a dispatched agent's credential are unchanged. When the create answer says `autoApproved: true` no person decides the request: the first claim is not delayed, `plan_connect` waits up to about 45 seconds for the key and answers signed in with no `approvalUrl` or `confirmCode`; a claim slower than that answers `{state: "signing_in"}`, still with no link. `autoApproved` is required in the create answer: a dashboard that does not send it fails the request loudly. A request left pending behaves as above.
21
22
  - **A launch token (DX-6801, DEC-522).** `plan_connect` and `card_connect` take an optional `launch_token`: the one-use token the session that started this one passed in its prompt. A signed-out session given one redeems it once per process through the public `POST /api/access-requests/claim` with `{secret: <token>, labelId: <this session's id>}` (`labelId` is sent only on a redeem); `approved` stores the key like an approval and answers signed in, with no access request. Any other answer (`claimed`, `expired`, 404, 409 `launch_grant_refused` with its `reason`, 429) spends the token and falls through to the usual person's request, the answer carrying a `note` that names why the token was not used. A signed-in session ignores the token.
22
23
 
23
24
  - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; this server reads it at boot, and `restart_notice` reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, the relay's heartbeat included, renews it), and is revocable from the account page.
@@ -54,7 +54,8 @@ function isCreated(value) {
54
54
  typeof v.secret === "string" &&
55
55
  typeof v.confirmCode === "string" &&
56
56
  typeof v.approvalUrl === "string" &&
57
- typeof v.pollMs === "number");
57
+ typeof v.pollMs === "number" &&
58
+ typeof v.autoApproved === "boolean");
58
59
  }
59
60
  export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
60
61
  const url = `${baseOf(dashboardUrl)}/api/access-requests`;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * DX-6764 (PLAN-128) - the secret that proves which machine a session's access request comes from.
3
+ *
4
+ * One random secret per machine and per dashboard, kept in `~/.config/danxbot/machine-secrets/<sha12(dashboard url)>.json`
5
+ * (mode 0600). A `session` access request sends it so a person can let later sessions from this machine sign in by themselves
6
+ * (the dashboard keeps only its hash). It is made the first time it is needed and never changes after, and it is only ever sent to
7
+ * the dashboard its file is named for: a different dashboard URL is a different file and a different secret.
8
+ *
9
+ * TWO SESSIONS ON A FRESH MACHINE. Both find no file and both make a candidate; a plain write-then-rename would let the second
10
+ * replace the first, so the two sessions would send different secrets. The candidate is written (durably) to its own temp name, then
11
+ * HARD-LINKED to the target: a link never replaces an existing file, so exactly one candidate wins, and every session reads the
12
+ * target back and uses what is there. A file that exists but is unusable is a loud error, never replaced: replacing it would
13
+ * silently orphan any grant a person made for the secret it held.
14
+ */
15
+ import { createHash, randomBytes } from "node:crypto";
16
+ import { linkSync, readFileSync, unlinkSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { writeFileDurable } from "./durable-write.js";
20
+ /** One canonical shape (CLAUDE.md Core Principle 1): a file of any other version is refused. */
21
+ export const MACHINE_SECRET_SCHEMA_VERSION = 1;
22
+ /** The dashboard refuses a shorter secret (`MACHINE_SECRET_MIN` in `src/dashboard/access-request-routes.ts`); 32 random bytes as hex is twice that. */
23
+ const SECRET_BYTES = 32;
24
+ const SECRET_MIN_LENGTH = 32;
25
+ const dashboardHash = (dashboardUrl) => createHash("sha256").update(dashboardUrl.replace(/\/+$/, "")).digest("hex").slice(0, 12);
26
+ export function machineSecretPath(dashboardUrl, home = homedir()) {
27
+ return join(home, ".config", "danxbot", "machine-secrets", `${dashboardHash(dashboardUrl)}.json`);
28
+ }
29
+ function unusable(path, why) {
30
+ return new Error(`[platform-mcp] the machine secret file ${path} is unusable: ${why}; fix or delete it, then call plan_connect again`);
31
+ }
32
+ /** The secret in `path`, or null when the file is absent. A file that is there but of another shape throws. */
33
+ function readSecret(path, dashboardUrl) {
34
+ let raw;
35
+ try {
36
+ raw = readFileSync(path, "utf-8");
37
+ }
38
+ catch (err) {
39
+ if (err.code === "ENOENT")
40
+ return null;
41
+ throw unusable(path, err.message);
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ }
47
+ catch (err) {
48
+ throw unusable(path, `not JSON (${err.message})`);
49
+ }
50
+ if (typeof parsed !== "object" || parsed === null)
51
+ throw unusable(path, "it is not an object");
52
+ const record = parsed;
53
+ if (record.schemaVersion !== MACHINE_SECRET_SCHEMA_VERSION) {
54
+ throw unusable(path, `schemaVersion is ${JSON.stringify(record.schemaVersion)}, not ${MACHINE_SECRET_SCHEMA_VERSION}`);
55
+ }
56
+ if (typeof record.secret !== "string" || record.secret.length < SECRET_MIN_LENGTH) {
57
+ throw unusable(path, `secret is not a string of at least ${SECRET_MIN_LENGTH} characters`);
58
+ }
59
+ if (typeof record.dashboardUrl !== "string" || record.dashboardUrl.replace(/\/+$/, "") !== dashboardUrl.replace(/\/+$/, "")) {
60
+ throw unusable(path, `it names the dashboard ${JSON.stringify(record.dashboardUrl)}, not ${dashboardUrl}`);
61
+ }
62
+ return record.secret;
63
+ }
64
+ /** This machine's secret for `dashboardUrl`, made (once, race-safe) if it does not exist yet. */
65
+ export function machineSecretFor(dashboardUrl, options = {}) {
66
+ const path = machineSecretPath(dashboardUrl, options.home);
67
+ const existing = readSecret(path, dashboardUrl);
68
+ if (existing !== null)
69
+ return existing;
70
+ const record = {
71
+ schemaVersion: MACHINE_SECRET_SCHEMA_VERSION,
72
+ dashboardUrl: dashboardUrl.replace(/\/+$/, ""),
73
+ secret: randomBytes(SECRET_BYTES).toString("hex"),
74
+ createdAt: new Date(options.now ?? Date.now()).toISOString(),
75
+ };
76
+ const candidate = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.candidate`;
77
+ writeFileDurable(candidate, JSON.stringify(record), { mode: 0o600 });
78
+ try {
79
+ linkSync(candidate, path);
80
+ }
81
+ catch (err) {
82
+ // Another session made the file between the read above and this link: its secret is the machine's.
83
+ if (err.code !== "EEXIST")
84
+ throw err;
85
+ }
86
+ finally {
87
+ unlinkSync(candidate);
88
+ }
89
+ const stored = readSecret(path, dashboardUrl);
90
+ if (stored === null)
91
+ throw unusable(path, "it vanished right after it was made");
92
+ return stored;
93
+ }
@@ -250,7 +250,7 @@ function resolveToolkitVersion(stamped, readManifest) {
250
250
  return stamped ?? readManifest();
251
251
  }
252
252
  var readOwnManifest = () => JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8")).version;
253
- var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.442", readOwnManifest);
253
+ var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.443", readOwnManifest);
254
254
  function envelope(now) {
255
255
  return { contract_version: CONTRACT_VERSION, toolkit_version: TOOLKIT_VERSION, profile: PROFILE, measured_at: now.toISOString() };
256
256
  }
@@ -29,6 +29,7 @@ import { hostname } from "node:os";
29
29
  import { claimAccessRequest, createAccessRequest } from "./access-request.js";
30
30
  import { credentialFingerprint } from "./credential.js";
31
31
  import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
32
+ import { machineSecretFor } from "./machine-secret.js";
32
33
  import { deleteSessionKey, writeSessionKey } from "./session-key.js";
33
34
  import { McpOutdatedError } from "./version-floor.js";
34
35
  /** Longest a `plan_connect` call waits for an approval already asked for. */
@@ -48,6 +49,9 @@ const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open t
48
49
  "The request stays open until this session ends, however long the user takes.";
49
50
  const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve; the request stays open until this session ends. Remind them of the " +
50
51
  "confirm code and the URL, then call `plan_connect` again.";
52
+ /** DX-6764: a machine grant approved the request; nothing for the user to open or confirm, the next call picks the sign-in up. */
53
+ const SIGNING_IN_INSTRUCTION = "This machine's auto-approval signs the session in; no approval link is involved. Call `plan_connect` again in a moment.";
54
+ const AUTO_APPROVED_EXPIRED_ERROR = "the dashboard approved this request by itself, but it expired before this process claimed the key; call plan_connect again";
51
55
  const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
52
56
  const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
53
57
  "it must be updated before this session can sign in";
@@ -271,21 +275,10 @@ export class SessionAccess {
271
275
  }
272
276
  }
273
277
  if (this.pending !== null) {
274
- const pending = this.pending;
275
- const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
276
- if (outcome === null)
277
- return notReady(this.requestAnswer("approval_pending", pending.request), false);
278
- this.pending = null;
279
- switch (outcome.kind) {
280
- case "approved":
281
- return { signedIn: true };
282
- case "denied":
283
- return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
284
- case "failed":
285
- return notReady({ state: "request_failed", error: outcome.error }, true);
286
- case "expired":
287
- note = EXPIRED_NOTE;
288
- }
278
+ const settled = await this.awaitPending(this.pending);
279
+ if (settled !== "expired")
280
+ return settled;
281
+ note = EXPIRED_NOTE;
289
282
  }
290
283
  const created = await this.createRequest(sessionId, title, note);
291
284
  // DX-6801: a refused or failed request carries the launch note too, so the reason is never lost.
@@ -294,6 +287,27 @@ export class SessionAccess {
294
287
  }
295
288
  return created;
296
289
  }
290
+ /**
291
+ * Wait up to `awaitMs` for the open request, then answer how it stands: signed in, still pending (the link to open, or for an
292
+ * auto-approved request the plain "signing in" state, which has no link), denied or failed. `expired` is for the caller: a person's
293
+ * request that lapsed is asked again, an auto-approved one is an error.
294
+ */
295
+ async awaitPending(pending) {
296
+ const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
297
+ if (outcome === null)
298
+ return notReady(this.requestAnswer("approval_pending", pending.request), false);
299
+ this.pending = null;
300
+ switch (outcome.kind) {
301
+ case "approved":
302
+ return { signedIn: true };
303
+ case "denied":
304
+ return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
305
+ case "failed":
306
+ return notReady({ state: "request_failed", error: outcome.error }, true);
307
+ case "expired":
308
+ return "expired";
309
+ }
310
+ }
297
311
  async createRequest(sessionId, title, note) {
298
312
  const repo = this.deps.readRepo === undefined ? null : await this.deps.readRepo();
299
313
  const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
@@ -304,6 +318,7 @@ export class SessionAccess {
304
318
  clientVersion: this.deps.clientVersion,
305
319
  ...(repo === null ? {} : { repo }),
306
320
  ...(this.deps.team === undefined ? {} : { team: this.deps.team }),
321
+ machineSecret: machineSecretFor(this.deps.dashboardUrl, { home: this.deps.home, now: this.now() }),
307
322
  });
308
323
  switch (created.kind) {
309
324
  case "dashboard_outdated":
@@ -316,10 +331,18 @@ export class SessionAccess {
316
331
  return notReady({ state: "request_refused", status: created.status, body: created.body }, true);
317
332
  case "created":
318
333
  this.pending = { request: created.request, settled: this.pollUntilSettled(created.request) };
334
+ // DX-6764: a machine grant approved it: no person decides, so the key is a claim away. Wait for it as a second call would,
335
+ // and never hand the agent a link.
336
+ if (created.request.autoApproved) {
337
+ const settled = await this.awaitPending(this.pending);
338
+ return settled === "expired" ? notReady({ state: "request_failed", error: AUTO_APPROVED_EXPIRED_ERROR }, true) : settled;
339
+ }
319
340
  return notReady({ ...this.requestAnswer("approval_required", created.request), ...(note ? { note } : {}) }, false);
320
341
  }
321
342
  }
322
343
  requestAnswer(state, request) {
344
+ if (request.autoApproved)
345
+ return { state: "signing_in", instruction: SIGNING_IN_INSTRUCTION };
323
346
  return {
324
347
  state,
325
348
  approvalUrl: request.approvalUrl,
@@ -329,7 +352,8 @@ export class SessionAccess {
329
352
  }
330
353
  // DX-4530: no give-up time - the polls are what keep the request open, until the dashboard answers how it ended.
331
354
  async pollUntilSettled(request) {
332
- let waitMs = request.pollMs;
355
+ // DX-6764: an auto-approved request is decided already, so its first claim is not delayed.
356
+ let waitMs = request.autoApproved ? 0 : request.pollMs;
333
357
  for (;;) {
334
358
  await this.sleep(waitMs);
335
359
  waitMs = request.pollMs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@50arms/platform-mcp",
3
- "version": "0.1.442",
3
+ "version": "0.1.443",
4
4
  "description": "Stdio MCP server fronting the 50 Arms dashboard API for agents (operator sessions and dispatched workers alike): the generic 50arms_api/50arms_api_list/50arms_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and 50arms_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",