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
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What a running agent did last, recorded and read (live tasks).
3
+ *
4
+ * The recorder sits beside the live log on the agent's stream and keeps one
5
+ * row per run: a fixed kind from live.ts's vocabulary and when. No text, no
6
+ * tool names, no arguments are ever kept, so nothing here can leak a secret.
7
+ * The reader picks the newer of that row and the run's latest progress
8
+ * checkpoint, and asks the runner's heartbeat whether the worker still
9
+ * answers. Neither side reads or stats a live-log file.
10
+ */
11
+ import { type RunActivity } from "./activity-line.js";
12
+ import type { Store } from "./store.js";
13
+ type Observer = {
14
+ observe(event: Record<string, unknown>): void;
15
+ close(): void;
16
+ };
17
+ /** Watch a run's stream for what the agent does, alongside its live log (which may be null). Fail-soft: a write
18
+ * that fails never touches the run. */
19
+ export declare function withRunActivity(liveLog: Observer | null, store: Pick<Store, "recordRunActivity">, run: number, clock: () => Date): Observer;
20
+ /** A live run's last activity: the newer of its recorded kind and its latest progress checkpoint, else when it
21
+ * started; and "offline" only when its registered worker's heartbeat has lapsed. */
22
+ export declare function runActivityOf(store: Store, run: {
23
+ id: number;
24
+ runner: string;
25
+ startedAt: string;
26
+ }, now: Date): RunActivity;
27
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a running agent did last, recorded and read (live tasks).
3
+ *
4
+ * The recorder sits beside the live log on the agent's stream and keeps one
5
+ * row per run: a fixed kind from live.ts's vocabulary and when. No text, no
6
+ * tool names, no arguments are ever kept, so nothing here can leak a secret.
7
+ * The reader picks the newer of that row and the run's latest progress
8
+ * checkpoint, and asks the runner's heartbeat whether the worker still
9
+ * answers. Neither side reads or stats a live-log file.
10
+ */
11
+ import { ACTIVITY_WORDS } from "./activity-line.js";
12
+ import { liveActivityKind } from "./live.js";
13
+ import { isAlive } from "./runner.js";
14
+ /** At most one write per run this often while the kind stays the same: the age is in seconds, not milliseconds. */
15
+ const SAME_KIND_EVERY_MS = 5_000;
16
+ /** Watch a run's stream for what the agent does, alongside its live log (which may be null). Fail-soft: a write
17
+ * that fails never touches the run. */
18
+ export function withRunActivity(liveLog, store, run, clock) {
19
+ let last = null;
20
+ return {
21
+ observe(event) {
22
+ liveLog?.observe(event);
23
+ const kind = liveActivityKind(event);
24
+ if (kind === null)
25
+ return;
26
+ try {
27
+ const now = clock();
28
+ if (last !== null && last.kind === kind && now.getTime() - last.at < SAME_KIND_EVERY_MS)
29
+ return;
30
+ store.recordRunActivity(run, kind, now);
31
+ last = { kind, at: now.getTime() };
32
+ }
33
+ catch { /* display state: the next event tries again */ }
34
+ },
35
+ close() { liveLog?.close(); },
36
+ };
37
+ }
38
+ /** A live run's last activity: the newer of its recorded kind and its latest progress checkpoint, else when it
39
+ * started; and "offline" only when its registered worker's heartbeat has lapsed. */
40
+ export function runActivityOf(store, run, now) {
41
+ const recorded = store.runActivity(run.id);
42
+ let what = ACTIVITY_WORDS["started"], at = run.startedAt;
43
+ if (recorded.kind !== null && recorded.at !== null && recorded.at >= at) {
44
+ what = ACTIVITY_WORDS[recorded.kind] ?? ACTIVITY_WORDS["tool"];
45
+ at = recorded.at;
46
+ }
47
+ if (recorded.progressAt !== null && recorded.progressAt > at) {
48
+ what = ACTIVITY_WORDS["progress"];
49
+ at = recorded.progressAt;
50
+ }
51
+ const worker = store.getRunner(run.runner)?.runner;
52
+ return { what, at, worker: worker === undefined || isAlive(worker, now) ? "online" : "offline" };
53
+ }
@@ -0,0 +1,17 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import { type Store } from "./store.js";
3
+ export type TaskViewer = {
4
+ name: string;
5
+ response: ServerResponse;
6
+ valid: () => boolean;
7
+ };
8
+ /** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
9
+ export declare function taskFingerprint(store: Store, root: string, now: Date, livenessMs?: number): string | null;
10
+ export type TaskRooms = {
11
+ /** Keep this page's stream on the task family until it closes; the first events say where it stands and who's here. */
12
+ join: (root: string, viewer: TaskViewer) => void;
13
+ /** Every stream, ended (the server is closing). */
14
+ close: () => void;
15
+ size: () => number;
16
+ };
17
+ export declare function createTaskRooms(fingerprint: (root: string) => string | null, tickMs?: number): TaskRooms;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The live task page (live tasks): a task open in several browsers stays the
3
+ * same in all of them, and each shows who else has it open. The same design
4
+ * as the live canvas (flow-live.ts):
5
+ *
6
+ * - A change nudge. While anyone has a task open, one indexed read a second,
7
+ * keyed by the task family's root, takes the family's fingerprint (its
8
+ * versions' states, runs, decisions, progress checkpoints, what the agent
9
+ * did last, and whether each live run's worker still answers); when it
10
+ * changes, every open page hears "change" and reads the task again the
11
+ * usual way. The nudge carries no task data.
12
+ * - Who's here. Everyone else with the task open, by name, one line per
13
+ * person however many pages they have. Memory only; it goes when the page
14
+ * closes or hides.
15
+ *
16
+ * Streams are refresh hints only: nothing here writes, and a page that can't
17
+ * keep one open reads itself on its usual beat.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { DEFAULT_LIVENESS_MS } from "./runner.js";
21
+ import { BUILT_IN } from "./store.js";
22
+ const TICK_MS = 1_000;
23
+ const CHECK_EVERY = 15;
24
+ const HEARTBEAT_EVERY = 15;
25
+ const PRESENCE_SETTLE_MS = 150;
26
+ /** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
27
+ export function taskFingerprint(store, root, now, livenessMs = DEFAULT_LIVENESS_MS) {
28
+ const row = store.handle.prepare(`WITH RECURSIVE family(ref, ext, depth) AS (
29
+ SELECT id, external_id, 0 FROM task_ref WHERE backend = ?3 AND external_id = ?1
30
+ UNION
31
+ SELECT child.id, child.external_id, family.depth + 1 FROM family
32
+ JOIN task_ref child ON child.backend = ?3 AND child.revision_of = family.ext WHERE family.depth < 63
33
+ )
34
+ SELECT (SELECT count(*) || ':' || group_concat(t.state || '@' || t.updated_at, ',') FROM family JOIN task t ON t.id = family.ext) AS tasks,
35
+ (SELECT count(*) || ':' || coalesce(max(r.id), 0) || ':' || coalesce(sum(r.outcome IS NULL), 0) || ':' || coalesce(max(coalesce(r.finished_at, r.started_at)), '') || ':' || coalesce(group_concat(r.phase), '')
36
+ FROM run r WHERE r.task_ref IN (SELECT ref FROM family)) AS runs,
37
+ (SELECT count(*) || ':' || coalesce(max(d.id), 0) || ':' || coalesce(sum(d.state = 'answered'), 0) FROM decision d JOIN run r ON r.id = d.run WHERE r.task_ref IN (SELECT ref FROM family)) AS decisions,
38
+ (SELECT coalesce(max(c.id), 0) FROM run_checkpoint c WHERE c.task_ref IN (SELECT ref FROM family)) AS progress,
39
+ (SELECT coalesce(max(a.at), '') FROM run_activity a JOIN run r ON r.id = a.run WHERE r.task_ref IN (SELECT ref FROM family) AND r.outcome IS NULL) AS activity,
40
+ (SELECT coalesce(group_concat(w.heartbeat_at > ?2), '') FROM run r JOIN runner w ON w.name = r.runner WHERE r.task_ref IN (SELECT ref FROM family) AND r.outcome IS NULL) AS workers
41
+ WHERE EXISTS (SELECT 1 FROM family)`).get(root, new Date(now.getTime() - livenessMs).toISOString(), BUILT_IN);
42
+ return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
43
+ }
44
+ export function createTaskRooms(fingerprint, tickMs = TICK_MS) {
45
+ const rooms = new Map();
46
+ const write = (viewer, event, data) => {
47
+ if (viewer.response.writableEnded || viewer.response.destroyed)
48
+ return;
49
+ viewer.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
50
+ };
51
+ /** Everyone on the task but you, once each, by name. */
52
+ const others = (room, me) => [...new Set([...room.viewers].map(one => one.name).filter(name => name !== me))].sort((a, b) => a.localeCompare(b));
53
+ const announce = (room) => {
54
+ if (room.settling !== null)
55
+ clearTimeout(room.settling);
56
+ room.settling = setTimeout(() => {
57
+ room.settling = null;
58
+ for (const viewer of room.viewers)
59
+ write(viewer, "here", { people: others(room, viewer.name) });
60
+ }, PRESENCE_SETTLE_MS);
61
+ room.settling.unref();
62
+ };
63
+ const leave = (root, viewer) => {
64
+ const room = rooms.get(root);
65
+ if (room === undefined || !room.viewers.delete(viewer))
66
+ return;
67
+ if (!viewer.response.writableEnded)
68
+ viewer.response.end();
69
+ if (room.viewers.size > 0) {
70
+ announce(room);
71
+ return;
72
+ }
73
+ if (room.timer !== null)
74
+ clearInterval(room.timer);
75
+ if (room.settling !== null)
76
+ clearTimeout(room.settling);
77
+ rooms.delete(root);
78
+ };
79
+ const tick = (root, room) => {
80
+ room.ticks += 1;
81
+ if (room.ticks % CHECK_EVERY === 0) {
82
+ // A signed-out or narrowed account stops hearing about the task.
83
+ for (const viewer of [...room.viewers])
84
+ if (!viewer.valid()) {
85
+ write(viewer, "gone", {});
86
+ leave(root, viewer);
87
+ }
88
+ if (!rooms.has(root))
89
+ return;
90
+ }
91
+ let now;
92
+ try {
93
+ now = fingerprint(root);
94
+ }
95
+ catch {
96
+ return; /* a busy database: the next tick reads again */
97
+ }
98
+ if (now === null) {
99
+ for (const viewer of [...room.viewers]) {
100
+ write(viewer, "gone", {});
101
+ leave(root, viewer);
102
+ }
103
+ return;
104
+ }
105
+ if (now !== room.last) {
106
+ room.last = now;
107
+ for (const viewer of room.viewers)
108
+ write(viewer, "change", { at: now });
109
+ }
110
+ else if (room.ticks % HEARTBEAT_EVERY === 0) {
111
+ for (const viewer of room.viewers)
112
+ if (!viewer.response.writableEnded)
113
+ viewer.response.write(": keep-alive\n\n");
114
+ }
115
+ };
116
+ return {
117
+ join(root, viewer) {
118
+ let room = rooms.get(root);
119
+ if (room === undefined) {
120
+ let last = null;
121
+ try {
122
+ last = fingerprint(root);
123
+ }
124
+ catch { /* the first tick reads it */ }
125
+ room = { viewers: new Set(), last, timer: null, ticks: 0, settling: null };
126
+ const created = room;
127
+ room.timer = setInterval(() => tick(root, created), tickMs);
128
+ room.timer.unref();
129
+ rooms.set(root, room);
130
+ }
131
+ room.viewers.add(viewer);
132
+ write(viewer, "change", { at: room.last });
133
+ write(viewer, "here", { people: others(room, viewer.name) });
134
+ announce(room);
135
+ viewer.response.once("close", () => leave(root, viewer));
136
+ },
137
+ close() {
138
+ for (const [root, room] of [...rooms])
139
+ for (const viewer of [...room.viewers])
140
+ leave(root, viewer);
141
+ },
142
+ size: () => [...rooms.values()].reduce((sum, room) => sum + room.viewers.size, 0),
143
+ };
144
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * API token notices (v111): 7 days and 1 day before a token expires, its person is told once each, in their own chats
3
+ * and browsers (a personal notification), and the ledger keeps it. Only the nearest notice newly due is sent, so a
4
+ * token made with a day left says so once. Replaced and revoked tokens get none. The same pass records the end of each
5
+ * rotated token whose overlap has passed; it already signs no one in (tokenLive), so a late pass grants nothing.
6
+ */
7
+ import { tokenLive } from "./api-tokens.js";
8
+ import type { Store } from "./store.js";
9
+ export declare const TOKEN_NOTICE_KIND = "api-token-expiry";
10
+ export declare function tokenNoticePass(store: Store, now: Date): {
11
+ sent: {
12
+ token: string;
13
+ days: number;
14
+ }[];
15
+ ended: number;
16
+ };
17
+ /** Whether a token's console card warns that it expires soon (within the first notice's days). */
18
+ export declare function expiresSoon(token: Parameters<typeof tokenLive>[0] & {
19
+ replacedBy: string | null;
20
+ }, now: number): boolean;
21
+ /** The loop beside the console: a pass a minute. */
22
+ export declare function startTokenNotices(store: Store, everyMs?: number): () => void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * API token notices (v111): 7 days and 1 day before a token expires, its person is told once each, in their own chats
3
+ * and browsers (a personal notification), and the ledger keeps it. Only the nearest notice newly due is sent, so a
4
+ * token made with a day left says so once. Replaced and revoked tokens get none. The same pass records the end of each
5
+ * rotated token whose overlap has passed; it already signs no one in (tokenLive), so a late pass grants nothing.
6
+ */
7
+ import { expiryNoticeDue, EXPIRY_NOTICE_DAYS, tokenLive } from "./api-tokens.js";
8
+ export const TOKEN_NOTICE_KIND = "api-token-expiry";
9
+ export function tokenNoticePass(store, now) {
10
+ const ended = store.endRotatedApiTokens(now);
11
+ const sent = [];
12
+ const at = now.getTime();
13
+ for (const token of store.apiTokens(null)) {
14
+ const days = expiryNoticeDue(token, at);
15
+ if (days === null)
16
+ continue;
17
+ const key = (one) => `${TOKEN_NOTICE_KIND}:${token.id}:${one}`;
18
+ // A nearer notice already sent covers this one.
19
+ if (EXPIRY_NOTICE_DAYS.filter(one => one <= days).some(one => store.handle.prepare("SELECT 1 AS hit FROM notification WHERE dedupe_key = ?").get(key(one)) !== undefined))
20
+ continue;
21
+ const fresh = store.enqueueNotification({
22
+ dedupeKey: key(days), kind: TOKEN_NOTICE_KIND, pushClass: "attention", link: "/settings/sessions", recipient: token.account,
23
+ subject: `Your API token ${token.name} expires ${days === 1 ? "tomorrow" : `in ${days} days`}`,
24
+ body: `It stops working ${token.expiresAt.slice(0, 16).replace("T", " ")} UTC. Rotate it with toolroll tokens rotate, or make a new one in Settings → Sessions & tokens.`,
25
+ source: { installation: true },
26
+ }, now);
27
+ if (!fresh)
28
+ continue;
29
+ store.recordAction({ at: now.toISOString(), actor: "system", repo: null, taskId: null, runId: null, action: `API token expiry notice: ${token.name}`, outcome: "notified", source: "access",
30
+ detail: `${token.account}'s · id ${token.id} · ${days} day${days === 1 ? "" : "s"} left` });
31
+ sent.push({ token: token.id, days });
32
+ }
33
+ return { sent, ended };
34
+ }
35
+ /** Whether a token's console card warns that it expires soon (within the first notice's days). */
36
+ export function expiresSoon(token, now) {
37
+ return expiryNoticeDue(token, now) !== null;
38
+ }
39
+ /** The loop beside the console: a pass a minute. */
40
+ export function startTokenNotices(store, everyMs = 60_000) {
41
+ let stopped = false;
42
+ const tick = () => { if (stopped)
43
+ return; try {
44
+ tokenNoticePass(store, new Date());
45
+ }
46
+ catch { /* the next pass tries again */ } };
47
+ const timer = setInterval(tick, everyMs);
48
+ timer.unref?.();
49
+ const first = setTimeout(tick, 5_000);
50
+ first.unref?.();
51
+ return () => { stopped = true; clearInterval(timer); clearTimeout(first); };
52
+ }
@@ -0,0 +1,21 @@
1
+ import type { Store } from "./store.js";
2
+ export declare const TOKENS_ACTIONS: readonly ["create", "list", "revoke", "rotate"];
3
+ /** The most a piped password may be: a password, not a file. */
4
+ export declare const PASSWORD_STDIN_BYTES = 4096;
5
+ export type TokensCliContext = {
6
+ store: Store;
7
+ write: (line: string) => void;
8
+ json: boolean;
9
+ clock: () => Date;
10
+ /** Who runs this: a remote caller (an API token, over /api/cli or /mcp) and the lead never manage tokens. */
11
+ caller: "local" | "remote" | "lead";
12
+ /** The remembered local login's name only (never its password): the default for --as. */
13
+ rememberedName: string | null;
14
+ /** Whether someone is at this terminal to answer a prompt. */
15
+ interactive: () => boolean;
16
+ ask: (question: string) => Promise<string>;
17
+ askHidden: (question: string) => Promise<string>;
18
+ /** Standard input, read whole up to `limit` bytes; null past the limit. */
19
+ readStdin: (limit: number) => Promise<string | null>;
20
+ };
21
+ export declare function runTokensCommand(positional: readonly string[], flags: ReadonlyMap<string, string | true>, context: TokensCliContext): Promise<number>;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `toolroll tokens create|list|revoke|rotate` (v111): a person's own API tokens, from the terminal of the machine that
3
+ * keeps the database. Every verb is a step-up: the person's own password, typed at a hidden prompt or piped with
4
+ * --password-stdin, never an argument, never the remembered login, a lead token or a remote caller's token. A new
5
+ * secret is written once, in the answer to create or rotate; nothing else ever shows it, and only its hash is kept.
6
+ */
7
+ import { envelopeJson } from "./envelope.js";
8
+ import { daysLeft, mintApiToken, ROTATION_OVERLAP_MAX_MINUTES, ROTATION_OVERLAP_MINUTES, TOKEN_DAYS, tokenLive, tokenRotationProblem, MCP_ROTATION_REFUSAL } from "./api-tokens.js";
9
+ import { canonicalProject, projectName } from "./project.js";
10
+ import { authenticateAccount } from "./scope.js";
11
+ export const TOKENS_ACTIONS = ["create", "list", "revoke", "rotate"];
12
+ /** The most a piped password may be: a password, not a file. */
13
+ export const PASSWORD_STDIN_BYTES = 4096;
14
+ /** Flags that would put a credential in argv. Refused by name, their values never echoed. */
15
+ const SECRET_FLAGS = ["password", "token", "token-file", "token-env"];
16
+ const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
17
+ export async function runTokensCommand(positional, flags, context) {
18
+ const { store, write, json } = context;
19
+ const action = positional[0];
20
+ const command = action === undefined ? "tokens" : `tokens ${action}`;
21
+ const fail = (reason, message, code = EXIT.refused) => { write(json ? envelopeJson({ ok: false, command, reason, message }) : message); return code; };
22
+ const succeed = (data, lines) => { write(json ? envelopeJson({ ok: true, command, ...data }) : lines.join("\n")); return EXIT.ok; };
23
+ const text = (name) => { const value = flags.get(name); return typeof value === "string" ? value : undefined; };
24
+ if (action === undefined || !TOKENS_ACTIONS.includes(action)) {
25
+ return fail("usage", `Use toolroll tokens ${TOKENS_ACTIONS.join("|")}.`, EXIT.usage);
26
+ }
27
+ if (context.caller === "remote")
28
+ return fail("remote-refused", "API tokens are managed in the console or on the server's own machine, never with a token.");
29
+ if (context.caller === "lead")
30
+ return fail("lead-refused", "The lead can't manage your API tokens. Run this yourself.");
31
+ const secretFlag = SECRET_FLAGS.find(name => flags.has(name));
32
+ if (secretFlag !== undefined)
33
+ return fail("usage", `--${secretFlag} isn't accepted here: type your password at the prompt, or pipe it with --password-stdin.`, EXIT.usage);
34
+ // Check the terms before asking for anything, so a typo never spends a password try.
35
+ const verb = action;
36
+ const terms = verb === "create" ? createTerms(flags) : null;
37
+ if (terms !== null && "problem" in terms)
38
+ return fail("usage", terms.problem, EXIT.usage);
39
+ const ref = positional[1]?.trim() ?? "";
40
+ if ((verb === "revoke" || verb === "rotate") && (ref === "" || positional.length > 2))
41
+ return fail("usage", `Use toolroll tokens ${verb} <name|id>.`, EXIT.usage);
42
+ if ((verb === "create" || verb === "list") && positional.length > 1)
43
+ return fail("usage", `toolroll tokens ${verb} takes no other words.`, EXIT.usage);
44
+ const overlap = verb === "rotate" ? overlapMinutes(text("overlap")) : null;
45
+ if (overlap !== null && typeof overlap === "string")
46
+ return fail("usage", overlap, EXIT.usage);
47
+ // Who, then their password: never the remembered login's password, never an argument.
48
+ let name = text("as")?.trim() ?? context.rememberedName ?? undefined;
49
+ const fromStdin = flags.has("password-stdin");
50
+ if (!fromStdin && (!context.interactive() || json))
51
+ return fail("usage", "Type your password at the prompt (run this in a terminal), or pipe it with --password-stdin.", EXIT.usage);
52
+ if (name === undefined || name === "") {
53
+ if (fromStdin || !context.interactive())
54
+ return fail("usage", "Say who you are with --as <your name>.", EXIT.usage);
55
+ name = (await context.ask("username: ")).trim();
56
+ }
57
+ let password;
58
+ if (fromStdin) {
59
+ const piped = await context.readStdin(PASSWORD_STDIN_BYTES);
60
+ password = piped === null ? null : piped.replace(/\r?\n$/, "");
61
+ if (password === null)
62
+ return fail("usage", "Pipe only your password into --password-stdin.", EXIT.usage);
63
+ }
64
+ else {
65
+ password = await context.askHidden("password: ");
66
+ }
67
+ if (name === "" || password === "")
68
+ return fail("unauthenticated", "Your name and password are needed.");
69
+ const signedIn = authenticateAccount(store, name, password);
70
+ password = null;
71
+ if (!signedIn.ok)
72
+ return fail(signedIn.reason === "locked" ? "locked" : "unauthenticated", signedIn.reason === "locked" ? "Too many wrong passwords: wait a while, then try again." : "That name and password don't match an account.");
73
+ const now = context.clock();
74
+ const mine = () => store.apiTokens(name);
75
+ if (verb === "list") {
76
+ const tokens = mine().filter(one => tokenLive(one, now.getTime())).map(one => listed(one, now));
77
+ return succeed({ tokens }, tokens.length === 0 ? ["You have no API tokens. Make one with toolroll tokens create."] : tokens.map(one => `${one.name} · ${one.id} · ${one.access === "act" ? "acts" : "reads"} · ${one.projects === null ? "all your projects" : one.projects.length === 0 ? "no projects" : one.projects.map(projectName).join(", ")} · ` +
78
+ `made ${one.createdAt.slice(0, 10)} · last used ${one.lastUsedAt === null ? "never" : one.lastUsedAt.slice(0, 10)} · ` +
79
+ (one.stopsAt !== null ? `replaced; stops ${one.stopsAt.slice(0, 16).replace("T", " ")} UTC` : `expires ${one.expiresAt.slice(0, 10)}${one.daysLeft <= 7 ? ` (in ${one.daysLeft} day${one.daysLeft === 1 ? "" : "s"})` : ""}`)));
80
+ }
81
+ if (verb === "create") {
82
+ const { tokenName, access, days, projectWords } = terms;
83
+ if (access === "act" && signedIn.role !== "approver")
84
+ return fail("viewer", "Your account can watch, not act, so its tokens can only read. Use --access read.");
85
+ const projects = projectWords === null ? null : resolveProjects(store, name, projectWords);
86
+ if (projects !== null && "problem" in projects)
87
+ return fail("unknown-project", projects.problem);
88
+ const minted = mintApiToken();
89
+ const expiresAt = new Date(now.getTime() + days * 86_400_000).toISOString();
90
+ store.createApiToken({ id: minted.id, account: name, name: tokenName, secretHash: minted.hash, access, expiresAt, by: name, projects: projects?.paths ?? null }, now);
91
+ const row = store.apiTokenSecret(minted.id).row;
92
+ return succeed({ token: minted.token, ...listed(row, now) }, shownOnce(row, minted.token));
93
+ }
94
+ const found = resolveToken(mine(), ref, now, verb === "rotate");
95
+ if ("problem" in found)
96
+ return fail(found.reason, found.problem);
97
+ if (verb === "revoke") {
98
+ store.revokeApiToken(found.row.id, name, now);
99
+ return succeed({ id: found.row.id, name: found.row.name }, [`Revoked ${found.row.name}. Anything using it stops working now.`]);
100
+ }
101
+ const rotationProblem = tokenRotationProblem(found.row);
102
+ if (rotationProblem !== null)
103
+ return fail("mcp-managed", rotationProblem);
104
+ const minted = mintApiToken();
105
+ const rotated = store.rotateApiToken(found.row.id, { id: minted.id, secretHash: minted.hash }, name, now, overlap * 60_000);
106
+ if (!rotated.ok && rotated.reason === "mcp-managed")
107
+ return fail("mcp-managed", MCP_ROTATION_REFUSAL);
108
+ if (!rotated.ok)
109
+ return fail(rotated.reason === "replaced" ? "replaced" : "not-found", rotated.reason === "replaced" ? "That token was already replaced. Rotate its replacement instead." : "No live token of yours has that name or id.");
110
+ const stops = store.apiTokenSecret(found.row.id).row.overlapUntil;
111
+ return succeed({ token: minted.token, replaced: { id: found.row.id, stopsAt: stops }, ...listed(rotated.row, now) }, [
112
+ ...shownOnce(rotated.row, minted.token),
113
+ `The old ${found.row.name} (${found.row.id}) keeps working until ${stops.slice(0, 16).replace("T", " ")} UTC, then stops.`,
114
+ ]);
115
+ }
116
+ function createTerms(flags) {
117
+ const text = (name) => { const value = flags.get(name); return typeof value === "string" ? value : undefined; };
118
+ const tokenName = (text("name") ?? "").trim().replace(/[\u0000-\u001f\u007f]+/g, " ");
119
+ if (tokenName === "" || tokenName.length > 60)
120
+ return { problem: "Name the token with --name, in 60 characters or fewer." };
121
+ const access = text("access");
122
+ if (access !== "read" && access !== "act")
123
+ return { problem: "Choose what it can do: --access read or --access act." };
124
+ const daysText = text("days");
125
+ const days = Number(daysText);
126
+ if (daysText === undefined || !TOKEN_DAYS.includes(days))
127
+ return { problem: `Choose when it expires: --days ${TOKEN_DAYS.join(", ")}.` };
128
+ const projectsText = text("projects");
129
+ const projectWords = projectsText === undefined ? null : projectsText.split(",").map(one => one.trim()).filter(one => one !== "");
130
+ if (projectWords !== null && projectWords.length === 0)
131
+ return { problem: "Name at least one project with --projects, or leave it out for all your projects." };
132
+ return { tokenName, access, days, projectWords };
133
+ }
134
+ function overlapMinutes(given) {
135
+ if (given === undefined)
136
+ return ROTATION_OVERLAP_MINUTES;
137
+ const minutes = Number(given);
138
+ return Number.isInteger(minutes) && minutes >= 0 && minutes <= ROTATION_OVERLAP_MAX_MINUTES ? minutes : `--overlap is whole minutes, 0 to ${ROTATION_OVERLAP_MAX_MINUTES}.`;
139
+ }
140
+ /** Each named project, by path or by its name, among the projects this person may use now. */
141
+ function resolveProjects(store, account, words) {
142
+ const mine = [...new Set([...store.listProjects().map(one => one.path), ...store.knownRepos()])].filter(repo => store.accountCanAccess(account, repo));
143
+ const paths = [];
144
+ for (const word of words) {
145
+ const exact = mine.find(repo => repo === word || repo === canonicalProject(word));
146
+ const named = mine.filter(repo => projectName(repo) === word);
147
+ const path = exact ?? (named.length === 1 ? named[0] : undefined);
148
+ if (path === undefined)
149
+ return { problem: named.length > 1 ? `More than one of your projects is called ${word}: give its path.` : `${word} isn't one of your projects.` };
150
+ if (!paths.includes(path))
151
+ paths.push(path);
152
+ }
153
+ return { paths };
154
+ }
155
+ /** A token of this person's by exact id, else by a name only one of their current tokens has. */
156
+ function resolveToken(tokens, ref, now, current) {
157
+ const live = tokens.filter(one => tokenLive(one, now.getTime()));
158
+ const byId = live.find(one => one.id === ref);
159
+ if (byId !== undefined) {
160
+ if (current && byId.replacedBy !== null)
161
+ return { reason: "replaced", problem: "That token was already replaced. Rotate its replacement instead." };
162
+ return { row: byId };
163
+ }
164
+ const named = live.filter(one => one.name === ref && one.replacedBy === null);
165
+ if (named.length > 1)
166
+ return { reason: "ambiguous", problem: `More than one of your tokens is called ${ref}: use its id (toolroll tokens list).` };
167
+ if (named.length === 0)
168
+ return { reason: "not-found", problem: "No live token of yours has that name or id." };
169
+ return { row: named[0] };
170
+ }
171
+ /** What may be shown about a token: everything but its secret, which is never kept. */
172
+ function listed(row, now) {
173
+ return {
174
+ id: row.id, name: row.name, access: row.access, projects: row.projects, createdAt: row.createdAt, lastUsedAt: row.lastUsedAt, expiresAt: row.expiresAt,
175
+ daysLeft: daysLeft(row.expiresAt, now.getTime()), stopsAt: row.replacedBy === null ? null : row.overlapUntil,
176
+ };
177
+ }
178
+ function shownOnce(row, token) {
179
+ return [
180
+ `Your API token ${row.name}, shown once. Only a hash is kept, so copy it now:`,
181
+ ` ${token}`,
182
+ `It ${row.access === "act" ? "acts as you (never approves)" : "reads"} in ${row.projects === null ? "all your projects" : row.projects.map(projectName).join(", ")} until ${row.expiresAt.slice(0, 10)}.`,
183
+ ];
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.45",
3
+ "version": "0.9.47",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"