toolroll 0.9.44 → 0.9.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/api-tokens.d.ts +33 -0
- package/dist/api-tokens.js +39 -0
- package/dist/audit-cli.d.ts +17 -0
- package/dist/audit-cli.js +86 -0
- package/dist/cli-http.d.ts +10 -16
- package/dist/cli-http.js +12 -2
- package/dist/cli.js +7 -1
- package/dist/contracts/cli.d.ts +47 -0
- package/dist/contracts/cli.js +11 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/mcp-oauth.d.ts +53 -0
- package/dist/contracts/mcp-oauth.js +40 -0
- package/dist/credentials-ui.d.ts +5 -0
- package/dist/credentials-ui.js +16 -4
- package/dist/desktop-update.d.ts +2 -2
- package/dist/dispatch.d.ts +2 -2
- package/dist/dispatch.js +8 -4
- package/dist/export.js +4 -3
- package/dist/lead-status.d.ts +7 -3
- package/dist/lead-status.js +21 -12
- package/dist/mcp-http.d.ts +6 -1
- package/dist/mcp-http.js +22 -4
- package/dist/mcp-oauth.d.ts +82 -0
- package/dist/mcp-oauth.js +479 -0
- package/dist/mcp-person.js +1 -1
- package/dist/operate-remote-arguments.js +2 -0
- package/dist/operate-remote.d.ts +13 -1
- package/dist/operate-remote.js +30 -4
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +86 -12
- package/dist/people-audit-ui.d.ts +14 -0
- package/dist/people-audit-ui.js +38 -0
- package/dist/public-access.d.ts +50 -0
- package/dist/public-access.js +87 -0
- package/dist/public-check.d.ts +37 -0
- package/dist/public-check.js +170 -0
- package/dist/remote-audit.d.ts +79 -0
- package/dist/remote-audit.js +161 -0
- package/dist/remote-run.d.ts +4 -0
- package/dist/request-budget-ui.d.ts +11 -0
- package/dist/request-budget-ui.js +31 -0
- package/dist/request-budget.d.ts +96 -0
- package/dist/request-budget.js +227 -0
- package/dist/serve.d.ts +4 -1
- package/dist/serve.js +120 -13
- package/dist/store.d.ts +118 -2
- package/dist/store.js +236 -7
- package/dist/surface.js +19 -2
- package/dist/token-notices.d.ts +22 -0
- package/dist/token-notices.js +52 -0
- package/dist/tokens-cli.d.ts +21 -0
- package/dist/tokens-cli.js +184 -0
- package/dist/work-summary.d.ts +4 -1
- package/dist/work-summary.js +1 -1
- package/package.json +1 -1
|
@@ -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/dist/work-summary.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export declare function openWorkDecisionOf(store: Store, taskRef: number, now: D
|
|
|
41
41
|
export declare function workDecisionAction(taskId: string, decision: NonNullable<WorkFacts["openDecision"]>, principal: WorkPrincipal): WorkAction;
|
|
42
42
|
/** Pure counterpart for callers already holding the same facts. */
|
|
43
43
|
export declare function workSummaryOf(facts: WorkFacts, principal: WorkPrincipal, resultDisplay?: DisplayStatus): WorkSummary;
|
|
44
|
-
export type WorkSummaryAccess =
|
|
44
|
+
export type WorkSummaryAccess = (
|
|
45
45
|
/** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "<name> is on it". */
|
|
46
46
|
{
|
|
47
47
|
principal: "operator";
|
|
@@ -51,6 +51,9 @@ export type WorkSummaryAccess =
|
|
|
51
51
|
} | {
|
|
52
52
|
principal: "coordinator";
|
|
53
53
|
repos: readonly string[];
|
|
54
|
+
}) & {
|
|
55
|
+
/** Remote task reads also restrict dependency details; omitted preserves existing local output. */
|
|
56
|
+
dependencyRepos?: readonly string[] | null;
|
|
54
57
|
};
|
|
55
58
|
/** Admission precedes reading question/proof bodies. Missing and foreign
|
|
56
59
|
* tasks share the same result. The caller must authenticate/revalidate
|
package/dist/work-summary.js
CHANGED
|
@@ -94,7 +94,7 @@ export function taskWorkSummaryOf(store, taskId, now, access) {
|
|
|
94
94
|
const publication = result === null ? null : store.publicationForRun(result.id);
|
|
95
95
|
const facts = {
|
|
96
96
|
id: task.id, title: task.title, repo: ref.repo, state: task.state, updatedAt: task.updatedAt,
|
|
97
|
-
dispatch: diagnoseTaskDispatch(store, taskId, now), liveRunId: live?.id ?? null,
|
|
97
|
+
dispatch: diagnoseTaskDispatch(store, taskId, now, access.dependencyRepos ?? null), liveRunId: live?.id ?? null,
|
|
98
98
|
unfinishedRunId: runs.find(one => one.outcome === null && one.role !== "reviewer")?.id ?? null,
|
|
99
99
|
control: taskControlOf(store, ref.id, now), openDecision: openWorkDecisionOf(store, ref.id, now),
|
|
100
100
|
result: result === null ? null : {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.46",
|
|
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"
|