toolroll 0.9.45 → 0.9.47

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/api-tokens.d.ts +33 -0
  5. package/dist/api-tokens.js +39 -0
  6. package/dist/audit-cli.d.ts +17 -0
  7. package/dist/audit-cli.js +86 -0
  8. package/dist/browser/workspace.js +25 -25
  9. package/dist/browser-workspace.d.ts +18 -1
  10. package/dist/browser-workspace.js +9 -3
  11. package/dist/builder.js +4 -2
  12. package/dist/cli-http.d.ts +5 -0
  13. package/dist/cli-http.js +12 -2
  14. package/dist/cli.js +7 -1
  15. package/dist/contracts/cli.d.ts +47 -0
  16. package/dist/contracts/cli.js +11 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/mcp-oauth.d.ts +53 -0
  20. package/dist/contracts/mcp-oauth.js +40 -0
  21. package/dist/credentials-ui.d.ts +5 -0
  22. package/dist/credentials-ui.js +16 -4
  23. package/dist/export.js +4 -3
  24. package/dist/flow-card-task.d.ts +2 -0
  25. package/dist/flow-card-task.js +57 -0
  26. package/dist/flow-live.d.ts +2 -2
  27. package/dist/flow-live.js +69 -8
  28. package/dist/flows-ui.js +15 -1
  29. package/dist/live.d.ts +14 -0
  30. package/dist/live.js +71 -22
  31. package/dist/mcp-http.d.ts +6 -1
  32. package/dist/mcp-http.js +22 -4
  33. package/dist/mcp-oauth.d.ts +82 -0
  34. package/dist/mcp-oauth.js +479 -0
  35. package/dist/mcp-person.js +1 -1
  36. package/dist/operate-remote-arguments.js +2 -0
  37. package/dist/operate-remote.d.ts +1 -1
  38. package/dist/operate-remote.js +7 -1
  39. package/dist/operate.d.ts +3 -0
  40. package/dist/operate.js +58 -2
  41. package/dist/people-audit-ui.d.ts +14 -0
  42. package/dist/people-audit-ui.js +38 -0
  43. package/dist/planner.js +4 -3
  44. package/dist/public-access.d.ts +50 -0
  45. package/dist/public-access.js +87 -0
  46. package/dist/public-check.d.ts +37 -0
  47. package/dist/public-check.js +170 -0
  48. package/dist/remote-audit.d.ts +79 -0
  49. package/dist/remote-audit.js +161 -0
  50. package/dist/request-budget-ui.d.ts +11 -0
  51. package/dist/request-budget-ui.js +31 -0
  52. package/dist/request-budget.d.ts +96 -0
  53. package/dist/request-budget.js +227 -0
  54. package/dist/serve.d.ts +9 -1
  55. package/dist/serve.js +211 -52
  56. package/dist/setup-guide.d.ts +2 -2
  57. package/dist/store.d.ts +134 -2
  58. package/dist/store.js +270 -7
  59. package/dist/surface.js +19 -2
  60. package/dist/task-activity.d.ts +27 -0
  61. package/dist/task-activity.js +53 -0
  62. package/dist/task-live.d.ts +17 -0
  63. package/dist/task-live.js +144 -0
  64. package/dist/token-notices.d.ts +22 -0
  65. package/dist/token-notices.js +52 -0
  66. package/dist/tokens-cli.d.ts +21 -0
  67. package/dist/tokens-cli.js +184 -0
  68. package/package.json +1 -1
package/dist/flow-live.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * The live canvas (v88): a flow open in several browsers stays the same in
3
3
  * all of them, and each shows who else has it open.
4
4
  *
5
- * - A change nudge. While anyone has a flow open, one cheap query a second
6
- * reads the flow's fingerprint (its revision, cards, moves, comments,
7
- * watchers, step runs and triggers); when it changes, every open page
5
+ * - A change nudge. While anyone has a flow open, a small read each second
6
+ * checks the flow's fingerprint (its revision, cards, moves, comments,
7
+ * watchers, step runs, linked task progress and triggers); when it changes, every open page
8
8
  * hears "change" and reads the flow again the usual way. The nudge
9
9
  * carries no card data: what a page shows is always what its own read
10
10
  * returned, under its own checks.
@@ -20,17 +20,78 @@ const TICK_MS = 1_000;
20
20
  const CHECK_EVERY = 15;
21
21
  const HEARTBEAT_EVERY = 15;
22
22
  const PRESENCE_SETTLE_MS = 150;
23
- /** What changes when anything on the canvas changes: one row, one read, kept short and opaque. */
24
- export function flowFingerprint(store, flow) {
25
- const row = store.handle.prepare(`SELECT f.state || '|' || f.revision || '|' || f.updated_at || '|' || coalesce(f.owner, '') AS head,
23
+ // Statements belong to their database, without keeping closed Stores alive.
24
+ const fingerprintStatements = new WeakMap();
25
+ function fingerprintStatement(store) {
26
+ const cached = fingerprintStatements.get(store.handle);
27
+ if (cached !== undefined)
28
+ return cached;
29
+ // Match Store.taskFamilyOf/taskFamilyProjection: built-in tasks, same-repo
30
+ // edges, a revision brief bound to the parent's run, and at most 63 edges.
31
+ // Walk up only from active cards, then down from their proven roots. A
32
+ // missing/invalid/cyclic/over-depth ancestry leaves the linked task alone.
33
+ const statement = store.handle.prepare(`WITH RECURSIVE linked AS MATERIALIZED (
34
+ SELECT DISTINCT tr.id AS ref_id, tr.external_id AS id, tr.repo, tr.revision_of, tr.revision_brief_artifact
35
+ FROM flow_card c JOIN task_ref tr ON tr.external_id = c.task AND tr.backend = 'built-in'
36
+ JOIN task t ON t.id = tr.external_id
37
+ WHERE c.flow = $flow AND c.state = 'active' AND tr.repo IS NOT NULL
38
+ ), ancestors(linked_ref, ref_id, id, repo, revision_of, revision_brief_artifact, depth) AS (
39
+ SELECT ref_id, ref_id, id, repo, revision_of, revision_brief_artifact, 0 FROM linked
40
+ UNION ALL
41
+ SELECT child.linked_ref, parent.id, parent.external_id, parent.repo, parent.revision_of, parent.revision_brief_artifact, child.depth + 1
42
+ FROM ancestors child
43
+ JOIN task_ref parent ON parent.backend = 'built-in' AND parent.external_id = child.revision_of AND parent.repo IS child.repo
44
+ JOIN task t ON t.id = parent.external_id
45
+ JOIN artifact brief ON brief.id = child.revision_brief_artifact AND brief.kind = 'revision-brief'
46
+ JOIN run source_run ON source_run.id = brief.run AND source_run.task_ref = parent.id
47
+ WHERE child.depth < 63
48
+ ), roots AS MATERIALIZED (
49
+ SELECT DISTINCT ref_id, id, repo FROM ancestors WHERE revision_of IS NULL
50
+ ), descendants(ref_id, id, repo, depth) AS (
51
+ SELECT ref_id, id, repo, 0 FROM roots
52
+ UNION ALL
53
+ SELECT child.id, child.external_id, child.repo, parent.depth + 1 FROM descendants parent
54
+ JOIN task_ref child ON child.backend = 'built-in' AND child.revision_of = parent.id AND child.repo IS parent.repo
55
+ JOIN task t ON t.id = child.external_id
56
+ JOIN artifact brief ON brief.id = child.revision_brief_artifact AND brief.kind = 'revision-brief'
57
+ JOIN run source_run ON source_run.id = brief.run AND source_run.task_ref = parent.ref_id
58
+ WHERE parent.depth < 63
59
+ ), members AS (
60
+ SELECT ref_id FROM descendants
61
+ UNION
62
+ SELECT ref_id FROM linked WHERE NOT EXISTS (
63
+ SELECT 1 FROM ancestors WHERE linked_ref = linked.ref_id AND revision_of IS NULL
64
+ )
65
+ ) SELECT f.state || '|' || f.revision || '|' || f.updated_at || '|' || coalesce(f.owner, '') AS head,
26
66
  (SELECT count(*) || ':' || coalesce(max(updated_at), '') FROM flow_card WHERE flow = f.id) AS cards,
27
67
  (SELECT coalesce(max(e.id), 0) FROM flow_event e JOIN flow_card c ON c.id = e.card WHERE c.flow = f.id) AS moves,
28
68
  (SELECT coalesce(max(m.id), 0) FROM flow_comment m JOIN flow_card c ON c.id = m.card WHERE c.flow = f.id) AS comments,
29
69
  (SELECT count(*) || ':' || coalesce(max(w.added_at), '') FROM flow_card_watcher w JOIN flow_card c ON c.id = w.card WHERE c.flow = f.id) AS watchers,
30
70
  (SELECT count(*) || ':' || coalesce(sum(r.attempts), 0) || ':' || coalesce(max(coalesce(r.finished_at, r.started_at)), '') || ':' || coalesce(sum(r.state = 'running'), 0)
31
71
  FROM flow_step_run r JOIN flow_card c ON c.id = r.card WHERE c.flow = f.id) AS runs,
32
- (SELECT count(*) || ':' || coalesce(max(updated_at), '') || ':' || coalesce(max(last_at), '') || ':' || coalesce(sum(failures), 0) FROM flow_trigger WHERE flow = f.id) AS triggers
33
- FROM flow f WHERE f.id = ?`).get(flow);
72
+ (SELECT count(*) || ':' || coalesce(max(updated_at), '') || ':' || coalesce(max(last_at), '') || ':' || coalesce(sum(failures), 0) FROM flow_trigger WHERE flow = f.id) AS triggers,
73
+ (SELECT json_group_array(json_array(tr.id, t.id, t.state, t.updated_at, tr.plan,
74
+ s.digest, s.approved_digest, s.approved_at,
75
+ (SELECT max(p.id) FROM run_checkpoint p WHERE p.task_ref = tr.id),
76
+ (SELECT q.lease_id FROM claim q WHERE q.task_ref = tr.id AND q.released_at IS NULL AND q.expires_at > $now ORDER BY q.lease_generation DESC LIMIT 1),
77
+ (SELECT json_group_array(json_array(r.id, r.outcome, r.finished_at, r.reason,
78
+ (SELECT json_array(k.status, k.exit_code, k.updated_at) FROM run_check k WHERE k.run = r.id)) ORDER BY r.id) FROM run r WHERE r.task_ref = tr.id),
79
+ (SELECT json_group_array(json_array(d.id, d.state, d.question, d.answered_at) ORDER BY d.id) FROM decision d JOIN run r ON r.id = d.run WHERE r.task_ref = tr.id),
80
+ (SELECT json_group_array(json_array(h.id, h.reason) ORDER BY h.id) FROM hold h WHERE h.task_ref = tr.id AND (h.until IS NULL OR h.until > $now)),
81
+ (SELECT max(a.id) FROM action_ledger a WHERE a.task_id = t.id)) ORDER BY tr.id)
82
+ FROM task_ref tr JOIN task t ON t.id = tr.external_id
83
+ LEFT JOIN task_scope s ON s.task_id = t.id
84
+ WHERE tr.id IN (SELECT ref_id FROM members)) AS tasks
85
+ FROM flow f WHERE f.id = $flow`);
86
+ fingerprintStatements.set(store.handle, statement);
87
+ return statement;
88
+ }
89
+ /** What changes when anything on the canvas changes: metadata only, kept short and opaque. */
90
+ export function flowFingerprint(store, flow, now = new Date()) {
91
+ // A revision can progress while its card still links to the original task,
92
+ // including a build in another project. The page read admits the viewer;
93
+ // this stream only carries a hash. No evidence files or logs are read.
94
+ const row = fingerprintStatement(store).get({ $flow: flow, $now: now.toISOString() });
34
95
  return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
35
96
  }
36
97
  export function createFlowRooms(fingerprint, tickMs = TICK_MS) {
package/dist/flows-ui.js CHANGED
@@ -14,6 +14,8 @@ import { mailboxReady } from "./mailbox.js";
14
14
  import { labelOf, nameOf } from "./teammate-admin.js";
15
15
  import { callWords, receiptWords } from "./teammate-tools.js";
16
16
  import { undoFor } from "./teammate-week.js";
17
+ import { flowCardTaskLine } from "./flow-card-task.js";
18
+ import { readFlowSecrets } from "./flow-secrets.js";
17
19
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
18
20
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
19
21
  export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
@@ -84,9 +86,19 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
84
86
  const sortZones = new Set(stages.filter(one => one.kind === "sort").map(one => one.id));
85
87
  const decisions = sortZones.size === 0 ? new Map() : store.flowSortDecisions(flow.id);
86
88
  const mates = new Map(store.teammates([flow.repo]).map(one => [one.id, one]));
89
+ const taskSecrets = new Map();
90
+ const secretsFor = (repo) => {
91
+ if (!taskSecrets.has(repo))
92
+ taskSecrets.set(repo, Object.values(readFlowSecrets(setup.dir, repo)));
93
+ return taskSecrets.get(repo);
94
+ };
95
+ const now = new Date();
87
96
  const cards = store.flowCards(flow.id, true).filter(card => card.state === "active" || Date.now() - Date.parse(card.updatedAt) < 7 * 86_400_000).map((card) => {
88
97
  const stage = stages.find(one => one.id === card.stage);
89
98
  const task = card.task ?? card.primaryTask;
99
+ // Build zones may file into another project; the flow alone does not admit its task's detail.
100
+ const taskRepo = card.task === null ? null : store.lookupRef(card.task)?.repo ?? null;
101
+ const canReadTask = taskRepo !== null && (taskRepo === flow.repo || (setup.repos.includes(taskRepo) && store.accountCanAccess(viewer.name, taskRepo)));
90
102
  const discussion = store.flowComments(card.id);
91
103
  const watchers = store.flowCardWatchers(card.id);
92
104
  const decider = stage?.kind === "approval" ? deciderOf(stage, flow) : null;
@@ -136,7 +148,9 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
136
148
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
137
149
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
138
150
  return {
139
- id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state, waiting: card.waiting,
151
+ id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
152
+ waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
153
+ ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable" : card.waiting,
140
154
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}` },
141
155
  createdBy: card.createdBy, updatedAt: card.updatedAt,
142
156
  canDecide,
package/dist/live.d.ts CHANGED
@@ -25,6 +25,20 @@ export declare function liveDir(root: string): string;
25
25
  /** The exact filename, from the numeric id and nothing else — no user
26
26
  * input is ever parsed into this path. */
27
27
  export declare function liveLogPath(root: string, runId: number): string;
28
+ /**
29
+ * The fixed tool vocabulary. Known harness tools map to OUR kinds and
30
+ * phrases; anything unrecognized gets the generic one. The tool's own
31
+ * name, arguments, and metadata never reach the file: a tool named after
32
+ * a secret stays unspoken.
33
+ */
34
+ export type LiveActivityKind = "session" | "message" | "edit" | "command" | "read" | "search" | "lookup" | "plan" | "delegate" | "tool";
35
+ export declare const LIVE_ACTIVITY_KINDS: readonly LiveActivityKind[];
36
+ /**
37
+ * What one stream event says the agent just did, as one fixed kind: the
38
+ * last-activity line's only input. Never text, names or arguments; an
39
+ * event that says nothing (results, deltas, subagent chatter) is null.
40
+ */
41
+ export declare function liveActivityKind(event: Record<string, unknown>): LiveActivityKind | null;
28
42
  /**
29
43
  * One stream event → zero or more display lines, in the fixed vocabulary.
30
44
  * Assistant TEXT is the agent's own words (sanitized below); tool use is a
package/dist/live.js CHANGED
@@ -32,28 +32,77 @@ export function liveDir(root) {
32
32
  export function liveLogPath(root, runId) {
33
33
  return join(liveDir(root), `${Math.trunc(runId)}.log`);
34
34
  }
35
+ export const LIVE_ACTIVITY_KINDS = ["session", "message", "edit", "command", "read", "search", "lookup", "plan", "delegate", "tool"];
36
+ const TOOL_KINDS = new Map([
37
+ ["Edit", "edit"],
38
+ ["Write", "edit"],
39
+ ["MultiEdit", "edit"],
40
+ ["NotebookEdit", "edit"],
41
+ ["Bash", "command"],
42
+ ["Read", "read"],
43
+ ["Glob", "search"],
44
+ ["Grep", "search"],
45
+ ["LS", "read"],
46
+ ["WebFetch", "lookup"],
47
+ ["WebSearch", "lookup"],
48
+ ["TodoWrite", "plan"],
49
+ ["Task", "delegate"],
50
+ ]);
51
+ const KIND_HEADLINES = {
52
+ session: "the agent session started",
53
+ message: "",
54
+ edit: "→ editing files",
55
+ command: "→ running a command",
56
+ read: "→ reading the code",
57
+ search: "→ searching the code",
58
+ lookup: "→ looking something up",
59
+ plan: "→ organizing its plan",
60
+ delegate: "→ delegating to a helper",
61
+ tool: "→ using a tool",
62
+ };
63
+ const toolKind = (name) => TOOL_KINDS.get(typeof name === "string" ? name : "") ?? "tool";
35
64
  /**
36
- * The fixed tool-headline vocabulary. Known harness tools map to OUR
37
- * phrases; anything unrecognized gets the generic line. The tool's own
38
- * name, arguments, and metadata never reach the file: a tool named after
39
- * a secret stays unspoken.
65
+ * What one stream event says the agent just did, as one fixed kind: the
66
+ * last-activity line's only input. Never text, names or arguments; an
67
+ * event that says nothing (results, deltas, subagent chatter) is null.
40
68
  */
41
- const TOOL_HEADLINES = new Map([
42
- ["Edit", "→ editing files"],
43
- ["Write", "→ editing files"],
44
- ["MultiEdit", "→ editing files"],
45
- ["NotebookEdit", "→ editing files"],
46
- ["Bash", "→ running a command"],
47
- ["Read", "→ reading the code"],
48
- ["Glob", "→ searching the code"],
49
- ["Grep", "→ searching the code"],
50
- ["LS", "→ reading the code"],
51
- ["WebFetch", "→ looking something up"],
52
- ["WebSearch", "→ looking something up"],
53
- ["TodoWrite", "→ organizing its plan"],
54
- ["Task", "→ delegating to a helper"],
55
- ]);
56
- const TOOL_HEADLINE_GENERIC = "→ using a tool";
69
+ export function liveActivityKind(event) {
70
+ try {
71
+ const type = String(event["type"] ?? "");
72
+ if ((type === "system" && String(event["subtype"] ?? "") === "init") || type === "thread.started" || type === "init")
73
+ return "session";
74
+ if (type === "item.completed") {
75
+ const item = event["item"];
76
+ const kind = item === undefined || item === null ? "" : String(item["type"] ?? "");
77
+ return kind === "agent_message" ? "message" : kind === "command_execution" ? "command" : kind === "file_change" ? "edit" : kind === "web_search" ? "lookup" : null;
78
+ }
79
+ if (type === "message")
80
+ return String(event["role"] ?? "") === "assistant" && event["delta"] !== true ? "message" : null;
81
+ if (type === "tool_call" || type === "tool_use")
82
+ return toolKind(event["name"] ?? event["tool"]?.["name"]);
83
+ if (type !== "assistant")
84
+ return null;
85
+ const parent = event["parent_tool_use_id"];
86
+ if (parent !== undefined && parent !== null)
87
+ return null;
88
+ const content = event["message"]?.["content"];
89
+ if (!Array.isArray(content))
90
+ return null;
91
+ let last = null;
92
+ for (const block of content) {
93
+ if (block === null || typeof block !== "object")
94
+ continue;
95
+ if (block["type"] === "tool_use")
96
+ last = toolKind(block["name"]);
97
+ else if (block["type"] === "text" && typeof block["text"] === "string" && block["text"].trim() !== "")
98
+ last = "message";
99
+ }
100
+ return last;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
57
106
  /** Strip ANSI escapes and every control character; tabs become spaces. */
58
107
  function stripControls(text) {
59
108
  return text
@@ -108,7 +157,7 @@ export function renderTranscriptLines(event) {
108
157
  }
109
158
  if (type === "tool_call" || type === "tool_use") {
110
159
  const name = event["name"] ?? event["tool"]?.["name"];
111
- return [TOOL_HEADLINES.get(typeof name === "string" ? name : "") ?? TOOL_HEADLINE_GENERIC];
160
+ return [KIND_HEADLINES[toolKind(name)]];
112
161
  }
113
162
  if (type !== "assistant")
114
163
  return [];
@@ -136,7 +185,7 @@ export function renderTranscriptLines(event) {
136
185
  }
137
186
  else if (kind === "tool_use") {
138
187
  const name = block["name"];
139
- lines.push(TOOL_HEADLINES.get(typeof name === "string" ? name : "") ?? TOOL_HEADLINE_GENERIC);
188
+ lines.push(KIND_HEADLINES[toolKind(name)]);
140
189
  }
141
190
  }
142
191
  return lines;
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import type { IncomingMessage, ServerResponse } from "node:http";
13
13
  import type { Store } from "./store.js";
14
- import { type RunOperateAs } from "./mcp-person.js";
14
+ import { type Person, type RunOperateAs } from "./mcp-person.js";
15
+ import { type Admission } from "./request-budget.js";
15
16
  export type McpHttpOptions = {
16
17
  store: Store;
17
18
  clock: () => Date;
@@ -20,7 +21,11 @@ export type McpHttpOptions = {
20
21
  evidenceRoot?: string;
21
22
  /** The console's own sign-in for an `so_` bearer: the address's tries, expiry, revocation, a removed account. */
22
23
  signedIn: (request: IncomingMessage) => boolean;
24
+ /** Where an MCP client finds how to sign in (mcp-oauth.ts), named on a 401; null when this address offers none. */
25
+ resourceMetadata?: (request: IncomingMessage) => string | null;
23
26
  /** Runs a person's command on the server (operate.ts); resolved from operate.ts when not injected. */
24
27
  runAs?: RunOperateAs;
28
+ /** A person token's request budget (request-budget.ts), charged once per request before its body is read. */
29
+ admit?: (person: Person) => Admission;
25
30
  };
26
31
  export declare function createMcpHttp(options: McpHttpOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
package/dist/mcp-http.js CHANGED
@@ -10,10 +10,12 @@
10
10
  * Host check runs before this route. A revoked or expired token answers 401 on its next request.
11
11
  */
12
12
  import { PACKAGE_VERSION } from "./version.js";
13
- import { parseApiToken, secretMatches } from "./api-tokens.js";
13
+ import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
14
+ import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
14
15
  import { authenticateCoordinator } from "./coordinator.js";
15
16
  import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
16
17
  import { callPersonTool, personTools } from "./mcp-person.js";
18
+ import { limitWords } from "./request-budget.js";
17
19
  /** The most decisions a coordinator credential is remembered to have read (propose_answer needs one). */
18
20
  const READ_DECISIONS_KEPT = 200;
19
21
  export function createMcpHttp(options) {
@@ -38,15 +40,20 @@ export function createMcpHttp(options) {
38
40
  // The secret is checked here too, so this door never rests on how signedIn reads the request.
39
41
  if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash))
40
42
  return null;
41
- if (kept.row.revokedAt !== null || Date.parse(kept.row.expiresAt) <= options.clock().getTime())
43
+ if (!tokenLive(kept.row, options.clock().getTime()))
42
44
  return null;
43
45
  const account = store.accountOf(kept.row.account);
44
46
  if (account === null || account.revokedAt !== null)
45
47
  return null;
48
+ // An MCP sign-in's token: only while its access is fresh, and only in the projects the person chose (mcp-oauth.ts).
49
+ if (!oauthTokenAllowed(store, kept.row.id, RESOURCE_PATH, options.clock()))
50
+ return null;
51
+ const granted = oauthProjects(store, kept.row.id);
52
+ const projects = tokenProjects(tokenProjects(account.projects, kept.row.projects), granted);
46
53
  return {
47
54
  kind: "person",
48
55
  person: {
49
- principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects: account.projects === null ? null : [...account.projects] },
56
+ principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects },
50
57
  role: account.role,
51
58
  tokenName: kept.row.name,
52
59
  },
@@ -100,7 +107,18 @@ export function createMcpHttp(options) {
100
107
  return refuse(response, 403, "browser requests are refused — connect an MCP client with your API token");
101
108
  const caller = callerOf(request);
102
109
  if (caller === null) {
103
- return refuse(response, 401, "sign in with your API token (Authorization: Bearer so_…) or a coordinator credential — passwords and cookies are not accepted here", { "www-authenticate": 'Bearer realm="toolroll-mcp"' });
110
+ const metadata = options.resourceMetadata?.(request) ?? null;
111
+ return refuse(response, 401, "sign in with your API token (Authorization: Bearer so_…) or a coordinator credential — passwords and cookies are not accepted here", { "www-authenticate": `Bearer realm="toolroll-mcp"${metadata === null ? "" : `, resource_metadata="${metadata}"`}` });
112
+ }
113
+ // A person's token is counted once per request, notifications included; a coordinator keeps its own proposal limit.
114
+ if (caller.kind === "person" && options.admit !== undefined) {
115
+ const admitted = options.admit(caller.person);
116
+ if (!admitted.ok && admitted.status === 503)
117
+ return refuse(response, 503, "request limits could not be checked; nothing ran — try again shortly");
118
+ if (!admitted.ok) {
119
+ request.resume();
120
+ return send(response, 429, { jsonrpc: "2.0", id: null, error: { code: -32000, message: limitWords(admitted.limit, admitted.retryAfter), data: { limit: admitted.limit, retryAfter: admitted.retryAfter } } }, { "retry-after": String(admitted.retryAfter) });
121
+ }
104
122
  }
105
123
  if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
106
124
  return refuse(response, 415, "send the JSON-RPC message as application/json");
@@ -0,0 +1,82 @@
1
+ /**
2
+ * MCP sign-in: OAuth 2.1 for /mcp, as the MCP authorization spec describes, so an engineer's MCP client connects
3
+ * without a pasted token. This console is both the authorization server and the protected resource.
4
+ *
5
+ * /mcp's 401 names /.well-known/oauth-protected-resource/mcp (RFC 9728), which names this console's
6
+ * /.well-known/oauth-authorization-server (RFC 8414). A client registers itself (RFC 7591, public clients only),
7
+ * sends the person to /oauth/authorize with a S256 PKCE challenge and resource=<origin>/mcp, and trades the code
8
+ * at /oauth/token for a short-lived access token and a refresh secret that rotates on every use.
9
+ *
10
+ * The person signs in with the console's own sign-in (password or identity provider, unchanged), then consents on
11
+ * one page: read or act, and which of their projects, confirmed with their password or a fresh provider check, the
12
+ * same step-up as making an API token. Nothing is granted without that form, its session CSRF value and a same-site
13
+ * post.
14
+ *
15
+ * A grant is an ordinary API token (api-tokens.ts): its access token is `so_<id>_<secret>`, it is listed and revoked
16
+ * with every other token (console Sessions & tokens, `toolroll tokens`), and /mcp runs it through the same person
17
+ * path. Its v113 binding (store.oauthGrant) adds what an ordinary token doesn't have: it works only at /mcp, its
18
+ * access secret lasts an hour, it is tied to the account generation that consented, and it reaches only the projects
19
+ * chosen (still intersected with the person's live access on every call). Only hashes are kept: of codes, access
20
+ * secrets and refresh secrets. Approvals, people and policy are never reachable: those have no tool and no scope.
21
+ */
22
+ import type { IncomingMessage, ServerResponse } from "node:http";
23
+ import type { Store } from "./store.js";
24
+ /** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
25
+ export declare const OAUTH_TIMES: {
26
+ readonly requestMs: number;
27
+ readonly codeMs: 60000;
28
+ readonly accessSeconds: 3600;
29
+ readonly grantDays: 30;
30
+ };
31
+ /** The most sign-ins waiting at once, and registered clients kept. */
32
+ export declare const OAUTH_LIMITS: {
33
+ readonly requests: 200;
34
+ readonly requestsPerSource: 20;
35
+ readonly clients: 500;
36
+ readonly clientsPerSource: 20;
37
+ };
38
+ export declare const OAUTH_SCOPES: readonly ["read", "act"];
39
+ export declare const RESOURCE_PATH = "/mcp";
40
+ export declare const resourceMetadataUrl: (origin: string) => string;
41
+ /**
42
+ * Whether an API token may sign in to this request's path now. An ordinary token: yes (its own checks are elsewhere).
43
+ * An MCP sign-in's token: only at /mcp, only while its access secret is fresh and the account is at the generation
44
+ * that consented.
45
+ */
46
+ export declare function oauthTokenAllowed(store: Store, tokenId: string, path: string, now: Date): boolean;
47
+ /** The projects an MCP sign-in's token was granted (the person's live access still applies), or null for an ordinary token. */
48
+ export declare const oauthProjects: (store: Store, tokenId: string) => string[] | null;
49
+ export type OAuthSession = {
50
+ name: string;
51
+ role: "approver" | "viewer";
52
+ csrf: string;
53
+ /** Signed in with an identity provider: its name, and whether it checked the person in the last ten minutes. */
54
+ sso: {
55
+ label: string;
56
+ fresh: boolean;
57
+ } | null;
58
+ };
59
+ export type OAuthHttpOptions = {
60
+ store: Store;
61
+ clock: () => Date;
62
+ /** This console's canonical origin for the request (the https public address, or this computer's loopback one); null: no sign-in here. */
63
+ originOf: (request: IncomingMessage) => string | null;
64
+ /** Trusted direct peer, or the last hop appended by the console's loopback proxy. */
65
+ requesterKey: (request: IncomingMessage) => string;
66
+ /** The browser session signed in by cookie, or null. A bearer never consents. */
67
+ session: (request: IncomingMessage) => OAuthSession | null;
68
+ /** A posted form came from a page of this console (its Origin, or Referer, names an allowed host). */
69
+ sameSite: (request: IncomingMessage) => boolean;
70
+ /** The step-up: the person's password, or (empty) a fresh identity-provider check. */
71
+ confirm: (session: OAuthSession, typed: string) => boolean;
72
+ /** The projects this person may choose: each a project the server knows that they can use now. */
73
+ projectsFor: (account: string) => string[];
74
+ };
75
+ /**
76
+ * A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
77
+ * credentials or fragment.
78
+ */
79
+ export declare function redirectAllowed(value: string): boolean;
80
+ /** A redirect matches a registered one exactly; on a loopback http address any port does (RFC 8252 §7.3). */
81
+ export declare function redirectMatches(presented: string, registered: readonly string[]): string | null;
82
+ export declare function createOAuthHttp(options: OAuthHttpOptions): (request: IncomingMessage, response: ServerResponse, url: URL) => Promise<boolean>;