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
@@ -2,6 +2,7 @@
2
2
  * produced by the existing trusted server renderers, never by a model-supplied
3
3
  * fragment. These projections do not authenticate, mutate or grant authority. */
4
4
  import type { TeamSnapshot } from './team-contract.js';
5
+ import type { RunActivity } from './activity-line.js';
5
6
  import type { BrandIconId } from './brand-icons.js';
6
7
  import type { AssignmentSnapshot } from './assignment.js';
7
8
  export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
@@ -26,7 +27,12 @@ export type BrowserNavigationItem = {
26
27
  href: string;
27
28
  active: boolean;
28
29
  count?: number;
30
+ /** What the count covers, said aloud and on hover: "3 need you in shop". */
31
+ countLabel?: string;
29
32
  };
33
+ /** The Tasks badge's words: the count and the projects it covers
34
+ * ("3 need you in shop", "1 needs you across all your projects"). */
35
+ export declare function needsYouLabelOf(count: number, projectName: string | null, saturated?: boolean): string;
30
36
  export type BrowserChatLink = {
31
37
  kind: 'project' | 'task';
32
38
  title: string;
@@ -53,6 +59,8 @@ export type BrowserCrewItem = {
53
59
  detail?: string;
54
60
  /** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
55
61
  lead?: string;
62
+ /** A running task: what its agent did last, and when. */
63
+ activity?: RunActivity;
56
64
  };
57
65
  export type BrowserMessage = {
58
66
  id: number;
@@ -419,6 +427,13 @@ export type BrowserTaskView = {
419
427
  action: BrowserLink | null;
420
428
  } | null;
421
429
  } | null;
430
+ /** What the agent did last on the live run, and when; the page words it against its own clock (activity-line.ts). */
431
+ activity?: RunActivity | null;
432
+ /** The task's live stream (/t/<id>/live): change nudges and who else has it open. Absent: the page reads on its beat. */
433
+ live?: {
434
+ href: string;
435
+ at: string | null;
436
+ } | null;
422
437
  /** Stop, on the Building card: the exact live build's stop form (posts its run id). */
423
438
  stop?: {
424
439
  action: string;
@@ -1166,6 +1181,8 @@ export type BrowserHomeAgent = {
1166
1181
  phase: string;
1167
1182
  project: string | null;
1168
1183
  since: string;
1184
+ /** What the agent did last, and when. */
1185
+ activity?: RunActivity;
1169
1186
  };
1170
1187
  export type BrowserHomeCount = {
1171
1188
  key: 'working' | 'waiting' | 'ready' | 'done';
@@ -1277,4 +1294,4 @@ export declare function browserProjectsOf(projects: readonly {
1277
1294
  name: string;
1278
1295
  path: string;
1279
1296
  }[]): BrowserProject[];
1280
- export declare function browserNavigationOf(path: string, project?: string | null, needsYou?: number): BrowserNavigationItem[];
1297
+ export declare function browserNavigationOf(path: string, project?: string | null, needsYou?: number, tasksProject?: string | null, needsYouLabel?: string): BrowserNavigationItem[];
@@ -1,6 +1,12 @@
1
1
  import { browserCrewFromIndex } from './browser-crew.js';
2
2
  export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
3
3
  import { workIndexPage } from './work-index.js';
4
+ /** The Tasks badge's words: the count and the projects it covers
5
+ * ("3 need you in shop", "1 needs you across all your projects"). */
6
+ export function needsYouLabelOf(count, projectName, saturated = false) {
7
+ const where = projectName === null ? 'across all your projects' : `in ${projectName}`;
8
+ return `${count}${saturated ? '+' : ''} ${count === 1 && !saturated ? 'needs' : 'need'} you ${where}`;
9
+ }
4
10
  /** Safe inside a script[type=application/json] element. JSON escaping alone
5
11
  * does not stop the HTML parser from closing that element at </script>. */
6
12
  export function serializeBrowserWorkspace(workspace) {
@@ -31,14 +37,14 @@ export function browserProjectsOf(projects) {
31
37
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(project.path)}` }];
32
38
  });
33
39
  }
34
- export function browserNavigationOf(path, project = null, needsYou = 0) {
40
+ export function browserNavigationOf(path, project = null, needsYou = 0, tasksProject = project, needsYouLabel) {
35
41
  const pathname = path.split('?')[0].split('#')[0];
36
42
  const knowledge = pathname === '/settings/knowledge' || pathname.startsWith('/settings/knowledge/');
37
43
  return [
38
44
  { label: 'Chat', href: '/chat', active: pathname === '/chat' },
39
- { label: 'Tasks', href: `/work${project === null ? '' : `?project=${encodeURIComponent(project)}`}`,
45
+ { label: 'Tasks', href: `/work${tasksProject === null ? '' : `?project=${encodeURIComponent(tasksProject)}`}`,
40
46
  active: pathname === '/work' || pathname === '/tasks' || pathname.startsWith('/t/') || pathname.startsWith('/r/'),
41
- ...(needsYou > 0 ? { count: needsYou } : {}) },
47
+ ...(needsYou > 0 ? { count: needsYou, ...(needsYouLabel === undefined ? {} : { countLabel: needsYouLabel }) } : {}) },
42
48
  { label: 'Flows', href: '/flows', active: pathname === '/flows' || pathname.startsWith('/flows/') },
43
49
  { label: 'Projects', href: '/projects', active: pathname === '/projects' },
44
50
  { label: 'Knowledge', href: `/settings/knowledge${project === null ? '' : `?repo=${encodeURIComponent(project)}`}`, active: knowledge },
package/dist/builder.js CHANGED
@@ -60,6 +60,7 @@ import { invokeAgent } from "./invoke.js";
60
60
  import { TELEGRAM_TOKEN_ENVS } from "./names.js";
61
61
  import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
62
62
  import { openLiveLog } from "./live.js";
63
+ import { withRunActivity } from "./task-activity.js";
63
64
  import { CheckProgressTracker } from "./check-progress.js";
64
65
  import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
65
66
  import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
@@ -1139,7 +1140,8 @@ export async function build(store, request) {
1139
1140
  // The live window (arc 1): display state beside the run, never evidence.
1140
1141
  // Every streaming transport emits events now (peek); a file that cannot
1141
1142
  // open is a null, and a null never costs a build.
1142
- const liveLog = openLiveLog(root, request.runId);
1143
+ // What the agent did last rides the same stream into one row (live tasks).
1144
+ const liveLog = withRunActivity(openLiveLog(root, request.runId), store, request.runId, clock);
1143
1145
  // The retry base (steering fix for run 1465's proof): the SAME pinned
1144
1146
  // base the machine will use to capture the sealed diff (settleProof
1145
1147
  // below, mirroring store.firstBuilderBase's own doc). Null on a first
@@ -1329,7 +1331,7 @@ export async function build(store, request) {
1329
1331
  omitEnv: AGENT_ENV_DENYLIST,
1330
1332
  ...(agent === undefined ? {} : { runner: agent }),
1331
1333
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
1332
- ...(liveLog === null ? {} : { onStreamEvent: (event) => liveLog.observe(event) }),
1334
+ onStreamEvent: (event) => liveLog.observe(event),
1333
1335
  // The receipt (finding 8): the stream proved the prompt reached the
1334
1336
  // agent — settle delivery NOW, durably, whatever happens to the run
1335
1337
  // later. The runner latches and isolates this callback; a throw
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Store } from './store.js';
3
3
  import type { Principal } from './operate.js';
4
4
  import { type RemoteCommandLookup } from './remote-command.js';
5
+ import { type Admission, type BudgetLimit } from './request-budget.js';
5
6
  /**
6
7
  * `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
7
8
  * server runs that same command as that person. There is no per-resource API here and no second policy: the transport
@@ -28,6 +29,8 @@ export type CliFailure = {
28
29
  ok: false;
29
30
  code: string;
30
31
  message: string;
32
+ limit?: BudgetLimit;
33
+ retryAfter?: number;
31
34
  };
32
35
  /** Inlined file contents, summed as UTF-8 bytes. */
33
36
  export declare const CLI_FILES_BYTES: number;
@@ -40,6 +43,8 @@ export type CliHttpOptions = {
40
43
  run: () => Promise<RunOperateAs | null>;
41
44
  /** Tests: command policy while the parallel command contract is not yet available. Defaults to COMMAND_GUIDE. */
42
45
  modeOf?: RemoteCommandLookup | undefined;
46
+ /** The token's request budget (request-budget.ts), charged once per request before its body is read. */
47
+ admit?: ((principal: Principal) => Admission) | undefined;
43
48
  store: Store;
44
49
  };
45
50
  /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
package/dist/cli-http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
2
+ import { limitWords } from './request-budget.js';
2
3
  /** Inlined file contents, summed as UTF-8 bytes. */
3
4
  export const CLI_FILES_BYTES = 256 * 1024;
4
5
  /** The whole JSON body: the files plus argv and JSON escaping headroom. */
@@ -6,10 +7,10 @@ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
6
7
  const ARGV_MAX = 256;
7
8
  const ARG_BYTES = 16 * 1024;
8
9
  const FILES_MAX = 16;
9
- function send(response, status, value) {
10
+ function send(response, status, value, headers = {}) {
10
11
  if (response.destroyed || response.writableEnded)
11
12
  return;
12
- response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
13
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
13
14
  response.end(JSON.stringify(value));
14
15
  }
15
16
  async function body(request) {
@@ -95,6 +96,15 @@ export async function handleCliHttp(request, response, options) {
95
96
  }
96
97
  if (!principal)
97
98
  return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
99
+ // Counted once here, however long the command then holds the request (a task wait is one request).
100
+ const admitted = options.admit === undefined ? { ok: true } : options.admit(principal);
101
+ if (!admitted.ok && admitted.status === 503)
102
+ return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
103
+ if (!admitted.ok) {
104
+ send(response, 429, { ok: false, code: 'rate-limited', message: limitWords(admitted.limit, admitted.retryAfter), limit: admitted.limit, retryAfter: admitted.retryAfter }, { 'retry-after': String(admitted.retryAfter) });
105
+ request.resume();
106
+ return true;
107
+ }
98
108
  let value;
99
109
  try {
100
110
  value = await body(request);
package/dist/cli.js CHANGED
@@ -208,6 +208,7 @@ export const OPERATE_COMMANDS = new Set([
208
208
  "task",
209
209
  "project",
210
210
  "ledger",
211
+ "audit",
211
212
  "storage",
212
213
  "monitoring",
213
214
  "integrations",
@@ -254,7 +255,7 @@ export const OPERATE_COMMANDS = new Set([
254
255
  "run",
255
256
  "routine",
256
257
  "flows",
257
- "config", "chat", "proposals", "mode", "chat-approval", "people", "keys",
258
+ "config", "chat", "proposals", "mode", "chat-approval", "people", "tokens", "keys",
258
259
  "setup",
259
260
  "verify",
260
261
  "intake",
@@ -304,6 +305,11 @@ export async function main(argv, write = line => console.log(line), mainOptions
304
305
  write("-o requires --json — the output file receives the machine envelope");
305
306
  return USAGE_EXIT;
306
307
  }
308
+ // A new API token is shown once, on screen: never saved to a file by the entry point.
309
+ if (outputFile !== undefined && args.find(one => !one.startsWith("-")) === "tokens") {
310
+ write("-o isn't used with tokens: a new token is shown once, never saved to a file");
311
+ return USAGE_EXIT;
312
+ }
307
313
  resetCapturedEnvelope();
308
314
  // Long-running commands (serve, up) emit their ONE envelope at startup,
309
315
  // when its URL is useful — so -o is preflighted BEFORE dispatch (an
@@ -714,6 +714,53 @@ export declare const COMMAND_OUTPUTS: {
714
714
  reason: z.ZodString;
715
715
  message: z.ZodOptional<z.ZodString>;
716
716
  }, z.core.$loose>], "ok">;
717
+ readonly audit: z.ZodDiscriminatedUnion<[z.ZodObject<{
718
+ envelopeVersion: z.ZodLiteral<1>;
719
+ ok: z.ZodLiteral<true>;
720
+ command: z.ZodLiteral<"audit">;
721
+ filters: z.ZodObject<{
722
+ person: z.ZodNullable<z.ZodString>;
723
+ token: z.ZodNullable<z.ZodString>;
724
+ source: z.ZodNullable<z.ZodEnum<{
725
+ api: "api";
726
+ mcp: "mcp";
727
+ }>>;
728
+ since: z.ZodNullable<z.ZodString>;
729
+ }, z.core.$loose>;
730
+ actions: z.ZodArray<z.ZodObject<{
731
+ id: z.ZodNumber;
732
+ at: z.ZodString;
733
+ person: z.ZodString;
734
+ token: z.ZodNullable<z.ZodString>;
735
+ source: z.ZodEnum<{
736
+ api: "api";
737
+ mcp: "mcp";
738
+ }>;
739
+ kind: z.ZodEnum<{
740
+ command: "command";
741
+ tool: "tool";
742
+ }>;
743
+ name: z.ZodString;
744
+ command: z.ZodString;
745
+ tool: z.ZodNullable<z.ZodString>;
746
+ repo: z.ZodNullable<z.ZodString>;
747
+ taskId: z.ZodNullable<z.ZodString>;
748
+ outcome: z.ZodEnum<{
749
+ error: "error";
750
+ ok: "ok";
751
+ refused: "refused";
752
+ }>;
753
+ reason: z.ZodNullable<z.ZodString>;
754
+ }, z.core.$loose>>;
755
+ limit: z.ZodNumber;
756
+ nextCursor: z.ZodNullable<z.ZodString>;
757
+ }, z.core.$loose>, z.ZodObject<{
758
+ envelopeVersion: z.ZodLiteral<1>;
759
+ ok: z.ZodLiteral<false>;
760
+ command: z.ZodLiteral<"audit">;
761
+ reason: z.ZodString;
762
+ message: z.ZodOptional<z.ZodString>;
763
+ }, z.core.$loose>], "ok">;
717
764
  readonly "skills list": z.ZodDiscriminatedUnion<[z.ZodObject<{
718
765
  envelopeVersion: z.ZodLiteral<1>;
719
766
  ok: z.ZodLiteral<true>;
@@ -148,6 +148,16 @@ const taskShowOutput = answers("task show", {
148
148
  proofAccepted: z.boolean(),
149
149
  stops: list,
150
150
  });
151
+ /** `audit` (remote-audit.ts): one page of remote actions, newest first, and the filters it applied. */
152
+ const auditOutput = answers("audit", {
153
+ filters: z.looseObject({ person: nullable(str), token: nullable(str), source: nullable(z.enum(["api", "mcp"])), since: nullable(str) }),
154
+ actions: rows({
155
+ id: int, at: str, person: str, token: nullable(str), source: z.enum(["api", "mcp"]), kind: z.enum(["command", "tool"]), name: str, command: str,
156
+ tool: nullable(str), repo: nullable(str), taskId: nullable(str), outcome: z.enum(["ok", "refused", "error"]), reason: nullable(str),
157
+ }),
158
+ limit: int,
159
+ nextCursor: nullable(str),
160
+ });
151
161
  const flowListed = z.looseObject({ id: int, name: str, repo: str, project: str, zones: z.array(str), triggers: int, cardsWaiting: int, needDecision: int });
152
162
  /**
153
163
  * The commands whose answers name their fields: the most-used ones (contract, the no-verb scan, status,
@@ -189,6 +199,7 @@ export const COMMAND_OUTPUTS = {
189
199
  "cap list": answers("cap list", { repo: str, capabilities: list }),
190
200
  gaps: answers("gaps", { repo: str, gaps: list }),
191
201
  reap: answers("reap", { count: int, released: list }),
202
+ audit: auditOutput,
192
203
  "skills list": answers("skills list", { guides: rows({ name: str, title: str, oneLiner: str }) }),
193
204
  };
194
205
  const declared = new Map(Object.entries(COMMAND_OUTPUTS));
@@ -80,6 +80,7 @@ export declare const CONSOLE_FORMS: {
80
80
  readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
81
81
  readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
82
82
  readonly sessions: FormContract<"access" | "action" | "days" | "everyone" | "name" | "password" | "session" | "token">;
83
+ readonly requestLimits: FormContract<"act-per-minute" | "action" | "password" | "per-day" | "per-minute" | "read-per-minute" | "target">;
83
84
  readonly signIn: FormContract<"action" | "client-id" | "client-secret" | "groups-claim" | "issuer" | "label" | "password" | "passwords" | "scopes" | `group-${string}` | `projects-${string}` | `role-${string}`>;
84
85
  readonly updates: FormContract<"password" | "version" | "when">;
85
86
  readonly retention: FormContract<"chat" | "checkouts" | "evidence" | "notifications" | "password">;
@@ -89,6 +89,7 @@ export const CONSOLE_FORMS = {
89
89
  policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
90
90
  approval: formContract("POST /settings/approval", ["repo", "password", "protect", "paths", "not_requester"]),
91
91
  sessions: formContract("POST /settings/sessions", ["everyone", "action", "session", "token", "name", "access", "days", "password"]),
92
+ requestLimits: formContract("POST /settings/request-limits", ["target", "action", "read-per-minute", "act-per-minute", "per-minute", "per-day", "password"]),
92
93
  signIn: formContract("POST /settings/sign-in", ["action", "password", "issuer", "client-id", "client-secret", "label", "scopes", "groups-claim", "passwords"], { prefixes: ["group-", "role-", "projects-"] }),
93
94
  updates: formContract("POST /settings/updates, /settings/updates/seen, /settings/updates/cancel", ["password", "version", "when"]),
94
95
  retention: formContract("POST /settings/retention", ["password", "evidence", "checkouts", "chat", "notifications"]),
@@ -0,0 +1,53 @@
1
+ /**
2
+ * MCP sign-in (mcp-oauth.ts): what an MCP client may send when it registers itself (RFC 7591) and when it trades a
3
+ * code or a refresh secret for tokens (RFC 6749 §4.1.3, §6, with PKCE, RFC 7636).
4
+ *
5
+ * A registration is read strictly for the fields Toolroll uses and ignores the rest (RFC 7591 §2: unknown metadata
6
+ * is ignored). Only public clients register: no secret is issued, so `token_endpoint_auth_method` is `none` when
7
+ * given. Whether a redirect is https, or http on 127.0.0.1 / [::1], is checked by mcp-oauth.ts after reading.
8
+ */
9
+ import { z } from "zod";
10
+ import { type ContractResult } from "./contract.js";
11
+ /** The most redirects one client registers, and how long each and the client's name may be. */
12
+ export declare const OAUTH_LIMITS: {
13
+ readonly redirects: 5;
14
+ readonly redirect: 512;
15
+ readonly name: 100;
16
+ };
17
+ export declare const OAUTH_GRANTS: readonly ["authorization_code", "refresh_token"];
18
+ export declare const oauthRegistrationSchema: z.ZodObject<{
19
+ redirect_uris: z.ZodArray<z.ZodString>;
20
+ client_name: z.ZodOptional<z.ZodString>;
21
+ token_endpoint_auth_method: z.ZodOptional<z.ZodLiteral<"none">>;
22
+ grant_types: z.ZodOptional<z.ZodArray<z.ZodEnum<{
23
+ authorization_code: "authorization_code";
24
+ refresh_token: "refresh_token";
25
+ }>>>;
26
+ response_types: z.ZodOptional<z.ZodArray<z.ZodLiteral<"code">>>;
27
+ }, z.core.$strip>;
28
+ /** A PKCE verifier (RFC 7636 §4.1) and a S256 challenge (43 base64url characters). */
29
+ export declare const PKCE_VERIFIER: RegExp;
30
+ export declare const PKCE_CHALLENGE: RegExp;
31
+ /** A token request, by grant type. Every value is one form field. */
32
+ export declare const oauthTokenRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
33
+ grant_type: z.ZodLiteral<"authorization_code">;
34
+ code: z.ZodString;
35
+ redirect_uri: z.ZodString;
36
+ client_id: z.ZodString;
37
+ code_verifier: z.ZodString;
38
+ resource: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$strip>, z.ZodObject<{
40
+ grant_type: z.ZodLiteral<"refresh_token">;
41
+ refresh_token: z.ZodString;
42
+ client_id: z.ZodString;
43
+ resource: z.ZodOptional<z.ZodString>;
44
+ scope: z.ZodOptional<z.ZodString>;
45
+ }, z.core.$strip>], "grant_type">;
46
+ export type OAuthRegistration = z.infer<typeof oauthRegistrationSchema>;
47
+ export type OAuthTokenRequest = z.infer<typeof oauthTokenRequestSchema>;
48
+ export declare const readOAuthRegistration: (input: unknown) => ContractResult<OAuthRegistration>;
49
+ /** A token request's form: a field given twice is refused (RFC 6749 §3.2); fields this grant doesn't take are ignored. */
50
+ export declare function readOAuthTokenRequest(form: URLSearchParams): ContractResult<OAuthTokenRequest> | {
51
+ ok: false;
52
+ repeated: true;
53
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * MCP sign-in (mcp-oauth.ts): what an MCP client may send when it registers itself (RFC 7591) and when it trades a
3
+ * code or a refresh secret for tokens (RFC 6749 §4.1.3, §6, with PKCE, RFC 7636).
4
+ *
5
+ * A registration is read strictly for the fields Toolroll uses and ignores the rest (RFC 7591 §2: unknown metadata
6
+ * is ignored). Only public clients register: no secret is issued, so `token_endpoint_auth_method` is `none` when
7
+ * given. Whether a redirect is https, or http on 127.0.0.1 / [::1], is checked by mcp-oauth.ts after reading.
8
+ */
9
+ import { z } from "zod";
10
+ import { parseContract } from "./contract.js";
11
+ /** The most redirects one client registers, and how long each and the client's name may be. */
12
+ export const OAUTH_LIMITS = { redirects: 5, redirect: 512, name: 100 };
13
+ export const OAUTH_GRANTS = ["authorization_code", "refresh_token"];
14
+ export const oauthRegistrationSchema = z.object({
15
+ redirect_uris: z.array(z.string().min(1).max(OAUTH_LIMITS.redirect)).min(1).max(OAUTH_LIMITS.redirects),
16
+ client_name: z.string().max(OAUTH_LIMITS.name).optional(),
17
+ token_endpoint_auth_method: z.literal("none").optional(),
18
+ grant_types: z.array(z.enum(OAUTH_GRANTS)).min(1).max(2).optional(),
19
+ response_types: z.array(z.literal("code")).min(1).max(1).optional(),
20
+ });
21
+ /** A PKCE verifier (RFC 7636 §4.1) and a S256 challenge (43 base64url characters). */
22
+ export const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
23
+ export const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
24
+ const bounded = z.string().min(1).max(2048);
25
+ /** A token request, by grant type. Every value is one form field. */
26
+ export const oauthTokenRequestSchema = z.discriminatedUnion("grant_type", [
27
+ z.object({ grant_type: z.literal("authorization_code"), code: bounded, redirect_uri: bounded, client_id: bounded, code_verifier: z.string().regex(PKCE_VERIFIER), resource: bounded.optional() }),
28
+ z.object({ grant_type: z.literal("refresh_token"), refresh_token: bounded, client_id: bounded, resource: bounded.optional(), scope: bounded.optional() }),
29
+ ]);
30
+ export const readOAuthRegistration = (input) => parseContract(oauthRegistrationSchema, input);
31
+ /** A token request's form: a field given twice is refused (RFC 6749 §3.2); fields this grant doesn't take are ignored. */
32
+ export function readOAuthTokenRequest(form) {
33
+ const fields = {};
34
+ for (const [key, value] of form) {
35
+ if (Object.hasOwn(fields, key))
36
+ return { ok: false, repeated: true };
37
+ fields[key] = value;
38
+ }
39
+ return parseContract(oauthTokenRequestSchema, fields);
40
+ }
@@ -16,6 +16,11 @@ export type CredentialsView = {
16
16
  })[];
17
17
  tokens: ApiTokenRow[];
18
18
  now: number;
19
+ /** Request limits (request-budget-ui.ts): words for each token's line, and the operator's disclosure. */
20
+ limits?: {
21
+ note: (token: ApiTokenRow) => string;
22
+ section: string;
23
+ };
19
24
  };
20
25
  export declare function credentialsHtml(view: CredentialsView, csrf: string, notice: {
21
26
  said?: string | null;
@@ -1,9 +1,11 @@
1
- import { TOKEN_DAYS } from "./api-tokens.js";
1
+ import { daysLeft, expiryNoticeDue, TOKEN_DAYS, tokenLive } from "./api-tokens.js";
2
+ import { projectName } from "./project.js";
2
3
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
4
  export const CREDENTIALS_CSS = `.credentials{max-width:820px;min-width:0}.credentials .card{padding:14px 16px;margin:12px 0}.credentials h2{font-size:1.05rem;margin:0 0 8px}` +
4
5
  `.credentials .rows{display:grid;gap:0;border:1px solid var(--so-line);border-radius:10px;overflow:hidden}.credentials .row-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--so-line)}` +
5
6
  `.credentials .row-item:last-child{border-bottom:0}.credentials .row-item p{margin:0}.credentials .row-item .meta{font-size:.85rem}.credentials form{margin:0}.credentials button{min-height:40px;white-space:nowrap}` +
6
7
  `.credentials .create{display:grid;gap:10px}.credentials .create label{display:grid;gap:6px}.credentials .create input,.credentials .create select{box-sizing:border-box;width:100%}.credentials .badge-here{font-size:.75rem;padding:1px 6px;border-radius:999px;background:var(--so-success-soft);color:var(--so-success);margin-left:6px}` +
8
+ `.credentials .row-item>div{min-width:0}.credentials .row-item p{overflow-wrap:anywhere}.credentials .soon{color:var(--so-warning,#9a6700);font-weight:600}` +
7
9
  `@media(max-width:600px){.credentials .row-item{flex-wrap:wrap}.credentials .create input,.credentials .create select{font-size:16px}}`;
8
10
  /** "Chrome on macOS", from a user agent, for a person to recognise a session. */
9
11
  export function deviceWords(agent) {
@@ -30,16 +32,26 @@ export function credentialsHtml(view, csrf, notice) {
30
32
  `${one.here ? "" : post({ action: "end-session", session: one.idHash }, "Sign out")}</div>`).join("")}</div>`;
31
33
  const others = view.sessions.filter(one => !one.here && one.account === view.who).length;
32
34
  const endOthers = others > 0 && !view.everyone ? post({ action: "end-others" }, `Sign out everywhere else (${others})`) : "";
33
- const live = view.tokens.filter(one => one.revokedAt === null && Date.parse(one.expiresAt) > view.now);
35
+ const live = view.tokens.filter(one => tokenLive(one, view.now));
36
+ // v111: its projects, and one state: when it stops (soon, said once and marked) or, once replaced, when it ends.
37
+ const ends = (one) => {
38
+ if (one.replacedBy !== null && one.overlapUntil !== null)
39
+ return `<span class="soon">Replaced · stops ${e(one.overlapUntil.slice(11, 16))} UTC</span>`;
40
+ if (expiryNoticeDue(one, view.now) === null)
41
+ return `Expires ${e(one.expiresAt.slice(0, 10))}`;
42
+ const days = daysLeft(one.expiresAt, view.now);
43
+ return `<span class="soon">Expires ${days <= 1 ? "within a day" : `in ${days} days`}</span>`;
44
+ };
45
+ const scope = (one) => one.projects === null ? "All projects" : one.projects.length === 0 ? "No projects" : one.projects.map(repo => e(projectName(repo))).join(", ");
34
46
  const tokens = live.length === 0 ? `<p class="meta">No API tokens.</p>` : `<div class="rows">${live.map(one => `<div class="row-item" data-token="${e(one.id)}"><div><p>${view.everyone ? `<strong>${e(one.account)}</strong> · ` : ""}${e(one.name)} · ${one.access === "act" ? "Can act" : "Read only"}</p>` +
35
- `<p class="meta">Expires ${e(one.expiresAt.slice(0, 10))} · last used ${e(when(one.lastUsedAt, view.now))}</p></div>${post({ action: "revoke-token", token: one.id }, "Revoke")}</div>`).join("")}</div>`;
47
+ `<p class="meta">${scope(one)} · ${ends(one)} · last used ${e(when(one.lastUsedAt, view.now))}${view.limits === undefined ? "" : ` · ${e(view.limits.note(one))}`}</p></div>${post({ action: "revoke-token", token: one.id }, "Revoke")}</div>`).join("")}</div>`;
36
48
  const create = `<details class="card"><summary>New API token</summary><form method="post" action="/settings/sessions" class="create">${hidden({ csrf, action: "create-token" })}` +
37
49
  `<label>Name<input type="text" name="name" maxlength="60" required placeholder="for example: CI"></label>` +
38
50
  `<label>It can<select name="access"><option value="read">Read (tasks, results, the ledger)</option><option value="act">Act as you (file and manage work; never approve)</option></select></label>` +
39
51
  `<label>Expires in<select name="days">${TOKEN_DAYS.map(days => `<option value="${days}"${days === 90 ? " selected" : ""}>${days} days</option>`).join("")}</select></label>` +
40
52
  `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button>Make the token</button></form></details>`;
41
53
  return `<section class="credentials">${note}${toggle}<h2>Signed in</h2>${sessions}${endOthers}<h2 style="margin-top:1.5rem">API tokens</h2>` +
42
- `<p class="meta">For scripts and CI: send <code>Authorization: Bearer &lt;token&gt;</code>. A token can't approve anything.</p>${tokens}${view.everyone ? "" : create}</section>`;
54
+ `<p class="meta">For scripts and CI: send <code>Authorization: Bearer &lt;token&gt;</code>. A token can't approve anything.</p>${tokens}${view.everyone ? "" : create}${view.limits?.section ?? ""}</section>`;
43
55
  }
44
56
  /** The token, once: a focused page with no script. */
45
57
  export function tokenShownHtml(name, token, access, expiresAt) {
package/dist/export.js CHANGED
@@ -29,14 +29,14 @@ import { readEmailSettings } from "./email-settings.js";
29
29
  import { SUBSCRIPTION_CAPABLE, readAuthMode } from "./keys.js";
30
30
  export const EXPORT_FORMAT = "standing-orders/export/v1";
31
31
  /** Tables that hold nothing but secrets or short-lived sign-in material, and derived search indexes. */
32
- const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair"]);
32
+ const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair", "oauth_code", "oauth_refresh"]);
33
33
  const DERIVED_TABLE = /^(sqlite_|memory_search)/;
34
34
  /** Columns that hold a secret (or what stands in for one): never exported. */
35
35
  const SECRET_COLUMNS = new Set([
36
36
  "api_token.secret_hash", "lead_credential.secret_hash", "approver.credential_hash", "coordinator_credential.credential_hash", "runner.credential_hash", "invite.token_hash",
37
37
  "flow_trigger.hook_hash", "held_session.cookie", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
38
38
  "chat_turn.credential_key", "mate_session.credential_key", "mate_turn.credential_key", "bridge_lease.push_url", "workflow_preview.token",
39
- "quota.credential_fp",
39
+ "quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
40
40
  ]);
41
41
  /** A column named like this holds a secret unless it is reviewed below; an unreviewed one is dropped. */
42
42
  const SECRET_NAME = /hash|secret|token|passw|cookie|csrf|credential|p256dh|^auth$|key|nonce|endpoint|url/i;
@@ -56,7 +56,7 @@ export function categoryOf(table) {
56
56
  [/^teammate/, "teammates"],
57
57
  [/^(flow|routine|workflow_)/, "flows"],
58
58
  [/^(chat_|mate_|team_|telegram_|slack_|discord_|teams_|notification|push_|bridge_lease)/, "chats"],
59
- [/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_)/, "people"],
59
+ [/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_|oauth_)/, "people"],
60
60
  [/^(run|artifact|claim|worktree$|execution_slot|contest|tournament_terms|fallback_cycle|fallback_transition|held_session|session_turn|attended_authorization|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
61
61
  [/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
62
62
  [/^(project|approval_policy|capability|verify_command|worktree_setup|backend_grant|intake_grant|publication_grant|operating_mode|knowledge_|learning_|memory_|skill_)/, "projects"],
@@ -67,6 +67,7 @@ export function categoryOf(table) {
67
67
  /** Extra shapes beside the evidence scanner's: this installation's own tokens and password hashes, and bearer headers. */
68
68
  const EXTRA_SHAPES = [
69
69
  /\bso_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
70
+ /\bsor_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
70
71
  /\blt_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
71
72
  /\bscrypt\$[^\s"',;]+/g,
72
73
  /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
@@ -0,0 +1,2 @@
1
+ import type { Store } from "./store.js";
2
+ export declare function flowCardTaskLine(store: Store, taskId: string, repo: string, now: Date, secrets?: readonly string[]): string;
@@ -0,0 +1,57 @@
1
+ /** The task behind an agent zone, read through the same status as its task page.
2
+ * Only saved milestone progress belongs on the card; tool calls and logs do not. */
3
+ import { assignmentOf } from "./assignment.js";
4
+ import { scanForSecrets } from "./evidence.js";
5
+ import { scrubIntegrationText } from "./integrations.js";
6
+ import { assignmentStatusFacts, taskStatusOf } from "./task-status.js";
7
+ import { taskWorkSummaryOf } from "./work-summary.js";
8
+ import { buildProgressOf } from "./workspace-ui.js";
9
+ /** Redact before shortening: a cut-off credential must never slip through a scanner.
10
+ * Structured arguments, commands and credential assignments aren't card copy. */
11
+ function cardReason(text, fallback, secrets) {
12
+ if (text === null)
13
+ return fallback;
14
+ const raw = /[{}\[\]`]|\b[\w.-]+\s*=|(?:^|\s)--[\w-]+|\b(?:arguments|tool_input|command|cmd|password|passwd|token|secret|api[_ -]?key|authorization)\s*:|\bcurl\s/i.test(text);
15
+ if (raw || secrets.some(secret => secret !== "" && text.includes(secret)) || scanForSecrets(text).length > 0)
16
+ return fallback;
17
+ const clean = scrubIntegrationText(text, secrets).replace(/[\p{Cf}]/gu, "").replace(/[.\s]+$/, "");
18
+ if (clean === "" || clean.includes("[hidden]"))
19
+ return fallback;
20
+ return clean.length > 100 ? `${clean.slice(0, 99).trimEnd()}…` : clean;
21
+ }
22
+ export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
23
+ const access = { principal: "operator", repos: [repo], dependencyRepos: [repo] };
24
+ const assignment = assignmentOf(store, taskId, now, access);
25
+ if (assignment === null)
26
+ return "Task unavailable";
27
+ const work = taskWorkSummaryOf(store, assignment.activeTaskId, now, access);
28
+ if (work === null)
29
+ return "Task unavailable";
30
+ const live = work.liveRunId === null ? null : store.getRun(work.liveRunId);
31
+ const facts = assignmentStatusFacts(assignment, { work: work.status, planning: live?.role === "planner" });
32
+ // The canvas reads no evidence files. Keep the machine's recorded check visible
33
+ // when the full receipt has no artifact root from which to read its check.
34
+ const check = assignment.receipt === null ? null : store.runCheckFor(assignment.receipt.runId);
35
+ if (facts.checks?.status === "unavailable" && check !== null)
36
+ facts.checks = { ...facts.checks, status: check.status, exitCode: check.exitCode };
37
+ const status = taskStatusOf(facts);
38
+ if (status.headline === "Needs you") {
39
+ const fallback = status.need?.action.label ?? "Open the task";
40
+ // The question itself is useful; routine approvals are clearer as the short action.
41
+ const what = status.need?.key === "answer" || status.need?.key === "other"
42
+ ? cardReason(status.sentence, fallback, secrets) : fallback;
43
+ return `Waiting on you: ${what}`;
44
+ }
45
+ if (status.headline === "Building" && live !== null) {
46
+ const ref = store.lookupRef(assignment.activeTaskId);
47
+ const checkpoint = store.latestCheckpointForRun(live.id);
48
+ const progress = buildProgressOf(checkpoint?.taskRef !== ref?.id ? null : checkpoint?.snapshot.milestones.map(one => ({ description: null, state: one.state, note: one.note })));
49
+ if (progress?.stuck != null)
50
+ return `Stuck: ${cardReason(progress.stuck.why, `step ${progress.step} of ${progress.total}`, secrets)}`;
51
+ if (progress !== null)
52
+ return `Building · step ${progress.step} of ${progress.total}`;
53
+ }
54
+ if (status.headline === "Failed")
55
+ return `Stuck: ${cardReason(status.sentence, "Open the task", secrets)}`;
56
+ return status.headline === "Ready for review" ? "Ready" : status.headline;
57
+ }
@@ -12,8 +12,8 @@ export type FlowViewer = {
12
12
  response: ServerResponse;
13
13
  valid: () => boolean;
14
14
  };
15
- /** What changes when anything on the canvas changes: one row, one read, kept short and opaque. */
16
- export declare function flowFingerprint(store: Store, flow: number): string | null;
15
+ /** What changes when anything on the canvas changes: metadata only, kept short and opaque. */
16
+ export declare function flowFingerprint(store: Store, flow: number, now?: Date): string | null;
17
17
  export type FlowRooms = {
18
18
  /** Keep this page's stream on the flow until it closes; the first event says where the flow stands. */
19
19
  join: (flow: number, viewer: FlowViewer) => void;