toolroll 0.9.57 → 0.9.58

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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/action-ledger.js +0 -4
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/cli-http.d.ts +7 -0
  11. package/dist/cli-http.js +30 -4
  12. package/dist/cli.js +2 -1
  13. package/dist/coordinator.d.ts +2 -1
  14. package/dist/coordinator.js +22 -31
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/export.js +2 -2
  17. package/dist/mcp-http.js +5 -5
  18. package/dist/operate.js +2 -1
  19. package/dist/password-bearer.d.ts +27 -0
  20. package/dist/password-bearer.js +59 -0
  21. package/dist/remote-command.js +0 -2
  22. package/dist/remote-exec.d.ts +19 -0
  23. package/dist/remote-exec.js +9 -8
  24. package/dist/request-budget.d.ts +1 -1
  25. package/dist/scope.js +2 -1
  26. package/dist/serve.js +15 -0
  27. package/dist/server/edge-refusal.d.ts +2 -0
  28. package/dist/server/edge-refusal.js +8 -6
  29. package/dist/server/guards.js +5 -4
  30. package/dist/server/http.d.ts +2 -0
  31. package/dist/server/remote.js +6 -13
  32. package/dist/server/route-table.js +0 -3
  33. package/dist/session-cli.d.ts +38 -9
  34. package/dist/session-cli.js +141 -154
  35. package/dist/session-contract.js +2 -2
  36. package/dist/session-remote.d.ts +19 -0
  37. package/dist/session-remote.js +86 -0
  38. package/dist/store.d.ts +28 -6
  39. package/dist/store.js +259 -93
  40. package/dist/team-cli.d.ts +3 -0
  41. package/dist/team-cli.js +23 -3
  42. package/dist/toolroll-update.js +3 -1
  43. package/package.json +1 -1
  44. package/dist/session-http.d.ts +0 -24
  45. package/dist/session-http.js +0 -170
  46. package/dist/session-server.d.ts +0 -17
  47. package/dist/session-server.js +0 -39
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.58 — 2026-10-09
4
+
5
+ - **One token kind.** Personal, lead and coordinator tokens now live in one
6
+ table with one check. Existing tokens keep working.
7
+ - **Sessions join the CLI API.** The sessions API is now part of the CLI API.
8
+ - **Passwords as tokens warn.** Sending your password as a token still works
9
+ this release but warns. Create a token with `toolroll tokens create`.
10
+
3
11
  ## 0.9.57 — 2026-10-09
4
12
 
5
13
  - **Truthful status.** Finished tasks read Complete everywhere. With checks
@@ -63,10 +63,6 @@ CREATE TRIGGER IF NOT EXISTS ledger_task_placed AFTER UPDATE OF repo ON task_ref
63
63
  WHEN OLD.repo IS NOT NEW.repo BEGIN
64
64
  ${event("strftime('%Y-%m-%dT%H:%M:%fZ','now')", "'system'", "NEW.repo", "NEW.external_id", "NULL", "task placed", "'recorded'")}
65
65
  END;
66
- CREATE TRIGGER IF NOT EXISTS ledger_coordinator_minted AFTER INSERT ON coordinator_credential BEGIN
67
- INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (NEW.created_at, NEW.created_by, NULL, NULL, NULL,
68
- 'coordinator minted: ' || NEW.name, 'minted', 'access', 'projects: ' || NEW.repos);
69
- END;
70
66
  CREATE TRIGGER IF NOT EXISTS ledger_task_registered AFTER INSERT ON task_ref BEGIN
71
67
  ${event("strftime('%Y-%m-%dT%H:%M:%fZ','now')", "'system'", "NEW.repo", "NEW.external_id", "NULL", "task registered", "'recorded'")}
72
68
  END;
package/dist/actor.d.ts CHANGED
@@ -9,13 +9,3 @@ export declare function currentActor(): Actor | null;
9
9
  export declare function claimActor(account: string): void;
10
10
  /** How the ledger names an actor. */
11
11
  export declare function actorLabel(actor: Actor): string;
12
- export declare function mintLeadToken(): {
13
- id: string;
14
- token: string;
15
- hash: string;
16
- };
17
- export declare function parseLeadToken(presented: string): {
18
- id: string;
19
- secret: string;
20
- } | null;
21
- export declare function leadSecretMatches(secret: string, keptHash: string): boolean;
package/dist/actor.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Who is acting right now, for pings that follow responsibility.
3
3
  *
4
4
  * A lead is an agent acting for one person under a lead token (`toolroll lead
5
- * token`): it signs in as that person, and its acts are recorded as "lead for
5
+ * token`, a `credential` row of kind 'lead', api-tokens.ts): it signs in as that person, and its acts are recorded as "lead for
6
6
  * <owner>". A person acting for themselves is recorded by name. Work whose
7
7
  * actor is the lead pings nobody, and nobody is pinged about their own act.
8
8
  *
@@ -10,7 +10,6 @@
10
10
  * long-running service: a worker's facts are nobody's act.
11
11
  */
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
14
13
  /** The slot is mutable so a command can name its person after it has verified their password. */
15
14
  const slot = new AsyncLocalStorage();
16
15
  /** Run `fn` with this actor (or an empty slot a verified sign-in may fill). */
@@ -30,19 +29,3 @@ export function claimActor(account) {
30
29
  export function actorLabel(actor) {
31
30
  return actor.lead ? `lead for ${actor.account}` : actor.account;
32
31
  }
33
- // ---- the lead token ---------------------------------------------------------
34
- /** `lt_<id>_<secret>`: shown once, only its hash kept. */
35
- const SHAPE = /^lt_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
36
- export function mintLeadToken() {
37
- const id = randomBytes(6).toString("hex"), secret = randomBytes(32).toString("base64url");
38
- return { id, token: `lt_${id}_${secret}`, hash: hashLeadSecret(secret) };
39
- }
40
- const hashLeadSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
41
- export function parseLeadToken(presented) {
42
- const match = SHAPE.exec(presented.trim());
43
- return match === null ? null : { id: match[1], secret: match[2] };
44
- }
45
- export function leadSecretMatches(secret, keptHash) {
46
- const a = Buffer.from(hashLeadSecret(secret), "hex"), b = Buffer.from(keptHash, "hex");
47
- return a.length === b.length && timingSafeEqual(a, b);
48
- }
@@ -1,5 +1,7 @@
1
1
  export type TokenAccess = "read" | "act";
2
2
  export type TokenPurpose = "api" | "mcp";
3
+ /** Who a credential stands for: a person (their API token), a person's lead, or a coordinator. */
4
+ export type CredentialKind = "person" | "lead" | "coordinator";
3
5
  export declare const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this token. You can still revoke it here.";
4
6
  /** Missing purpose is an old ordinary token; an unrecognised stored purpose is never treated as ordinary. */
5
7
  export declare const tokenPurpose: (value: unknown) => TokenPurpose;
@@ -7,18 +9,47 @@ export declare const tokenRotationProblem: (token: {
7
9
  purpose: TokenPurpose;
8
10
  }) => string | null;
9
11
  export declare const TOKEN_DAYS: readonly [30, 90, 365];
10
- export declare function mintApiToken(): {
12
+ /** What a presented secret says it is, before any check. A coordinator's secret has no prefix and is hashed whole. */
13
+ export type PresentedCredential = {
14
+ kind: "person" | "lead";
15
+ id: string;
16
+ secret: string;
17
+ } | {
18
+ kind: "coordinator";
19
+ secret: string;
20
+ };
21
+ /** A new credential of `kind`: its id, the secret shown once, and the only thing kept, its hash. */
22
+ export declare function mintCredential(kind: CredentialKind): {
11
23
  id: string;
12
24
  secret: string;
13
25
  token: string;
14
26
  hash: string;
15
27
  };
16
28
  export declare const hashSecret: (secret: string) => string;
17
- /** The id and secret of something shaped like a token, or null. */
29
+ /** Read a presented credential's kind and parts; null for nothing at all. A lead token may carry surrounding space. */
30
+ export declare function parseCredential(presented: string): PresentedCredential | null;
31
+ export declare const mintApiToken: () => {
32
+ id: string;
33
+ secret: string;
34
+ token: string;
35
+ hash: string;
36
+ };
37
+ export declare const mintLeadToken: () => {
38
+ id: string;
39
+ secret: string;
40
+ token: string;
41
+ hash: string;
42
+ };
43
+ /** The id and secret of something shaped like an API token, or null. */
18
44
  export declare function parseApiToken(presented: string): {
19
45
  id: string;
20
46
  secret: string;
21
47
  } | null;
48
+ /** The id and secret of something shaped like a lead token, or null. */
49
+ export declare function parseLeadToken(presented: string): {
50
+ id: string;
51
+ secret: string;
52
+ } | null;
22
53
  /** Whether the secret is the one whose hash was kept, in constant time. */
23
54
  export declare function secretMatches(secret: string, keptHash: string): boolean;
24
55
  /** v111: how long a rotated token keeps working beside its replacement, by default and at most. */
@@ -1,8 +1,9 @@
1
1
  /**
2
- * API tokens (v101): what scripts and CI use instead of a password on each
3
- * request. A token belongs to one person, reads or acts (never more than the
4
- * person may), always expires (a year at most), is shown once, and only its
5
- * hash is kept. `Authorization: Bearer so_<id>_<secret>`.
2
+ * Machine credentials (D7, v118): one codec and one table (`credential`) for every kind. A person's API token is
3
+ * what scripts and CI use instead of a password on each request: it belongs to one person, reads or acts (never
4
+ * more than the person may), always expires (a year at most), is shown once, and only its hash is kept.
5
+ * `Authorization: Bearer so_<id>_<secret>`. A lead token (`lt_<id>_<secret>`) is an agent acting for its owner; a
6
+ * coordinator's secret (no prefix, hashed whole) files proposals over MCP. Each kind keeps its own policy.
6
7
  *
7
8
  * A token never passes a step-up: approvals and other password ceremonies
8
9
  * stay a person's act in the console.
@@ -13,16 +14,43 @@ export const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this toke
13
14
  export const tokenPurpose = (value) => value === undefined || value === "api" ? "api" : "mcp";
14
15
  export const tokenRotationProblem = (token) => token.purpose === "api" ? null : MCP_ROTATION_REFUSAL;
15
16
  export const TOKEN_DAYS = [30, 90, 365];
16
- const SHAPE = /^so_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
17
- export function mintApiToken() {
18
- const id = randomBytes(6).toString("hex"), secret = randomBytes(32).toString("base64url");
19
- return { id, secret, token: `so_${id}_${secret}`, hash: hashSecret(secret) };
17
+ /** The prefixed shapes: so_ (a person's API token) and lt_ (a lead token), each `<prefix>_<12 hex>_<43 base64url>`. */
18
+ const PRESENTED = /^(so|lt)_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
19
+ const PREFIX = { person: "so", lead: "lt" };
20
+ const CID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
21
+ /** A new credential of `kind`: its id, the secret shown once, and the only thing kept, its hash. */
22
+ export function mintCredential(kind) {
23
+ const secret = randomBytes(32).toString("base64url");
24
+ if (kind === "coordinator") {
25
+ let id = "";
26
+ for (const byte of randomBytes(12))
27
+ id += CID_ALPHABET[byte % CID_ALPHABET.length];
28
+ return { id, secret, token: secret, hash: hashSecret(secret) };
29
+ }
30
+ const id = randomBytes(6).toString("hex");
31
+ return { id, secret, token: `${PREFIX[kind]}_${id}_${secret}`, hash: hashSecret(secret) };
20
32
  }
21
33
  export const hashSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
22
- /** The id and secret of something shaped like a token, or null. */
34
+ /** Read a presented credential's kind and parts; null for nothing at all. A lead token may carry surrounding space. */
35
+ export function parseCredential(presented) {
36
+ if (presented === "")
37
+ return null;
38
+ const match = PRESENTED.exec(presented) ?? (/^lt_/.test(presented.trim()) ? PRESENTED.exec(presented.trim()) : null);
39
+ if (match !== null)
40
+ return { kind: match[1] === "so" ? "person" : "lead", id: match[2], secret: match[3] };
41
+ return { kind: "coordinator", secret: presented };
42
+ }
43
+ export const mintApiToken = () => mintCredential("person");
44
+ export const mintLeadToken = () => mintCredential("lead");
45
+ /** The id and secret of something shaped like an API token, or null. */
23
46
  export function parseApiToken(presented) {
24
- const match = SHAPE.exec(presented);
25
- return match === null ? null : { id: match[1], secret: match[2] };
47
+ const parsed = parseCredential(presented);
48
+ return parsed?.kind === "person" && PRESENTED.test(presented) ? { id: parsed.id, secret: parsed.secret } : null;
49
+ }
50
+ /** The id and secret of something shaped like a lead token, or null. */
51
+ export function parseLeadToken(presented) {
52
+ const parsed = parseCredential(presented);
53
+ return parsed?.kind === "lead" ? { id: parsed.id, secret: parsed.secret } : null;
26
54
  }
27
55
  /** Whether the secret is the one whose hash was kept, in constant time. */
28
56
  export function secretMatches(secret, keptHash) {
@@ -14,7 +14,7 @@ const ownerSql = `COALESCE((SELECT 'lead:'||o.lead FROM team_task_owner o WHERE
14
14
  function admittedRepos(store, owner) {
15
15
  if (owner.kind !== "coordinator" || typeof owner.id !== "string" || owner.id.length === 0)
16
16
  return null;
17
- const row = store.handle.prepare("SELECT repos,revoked_at FROM coordinator_credential WHERE cid = ?").get(owner.id);
17
+ const row = store.handle.prepare("SELECT projects_json AS repos,revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(owner.id);
18
18
  if (!row || row["revoked_at"] !== null)
19
19
  return null;
20
20
  try {
@@ -92,7 +92,7 @@ export function noteAssignmentStatus(store, snapshot, now) {
92
92
  const claimed = store.handle.prepare("SELECT actor FROM action_ledger WHERE task_id=? AND action='assignment claimed' AND source='work' ORDER BY id DESC LIMIT 1").get(snapshot.rootId);
93
93
  if ((claimed === undefined ? ref.coordinatorCid : String(claimed["actor"]).replace(/^coordinator:/, "")) !== owner.id)
94
94
  return null;
95
- const credential = store.handle.prepare("SELECT repos FROM coordinator_credential WHERE cid=? AND revoked_at IS NULL").get(owner.id);
95
+ const credential = store.handle.prepare("SELECT projects_json AS repos FROM credential WHERE id=? AND kind='coordinator' AND revoked_at IS NULL").get(owner.id);
96
96
  let repos;
97
97
  try {
98
98
  repos = JSON.parse(String(credential?.["repos"] ?? "null"));
@@ -73,7 +73,8 @@ function ownerOf(store, rootId, repo) {
73
73
  const id = claim === undefined ? origin : String(claim["actor"]).replace(/^coordinator:/, "");
74
74
  if (!id)
75
75
  return null;
76
- const credential = store.handle.prepare("SELECT name,repos,revoked_at FROM coordinator_credential WHERE cid = ?").get(id);
76
+ // A ledger actor names a coordinator by the id it had when it acted (v118 keeps that as public_id).
77
+ const credential = store.handle.prepare("SELECT name,projects_json AS repos,revoked_at FROM credential WHERE kind = 'coordinator' AND (id = ? OR public_id = ?) ORDER BY id = ? DESC LIMIT 1").get(id, id, id);
77
78
  let repos = [];
78
79
  try {
79
80
  repos = JSON.parse(String(credential?.["repos"] ?? "[]"));
@@ -379,7 +380,7 @@ function readinessOf(store, receipt, repo, review) {
379
380
  function admittedOwner(store, taskId, owner, now, root) {
380
381
  if (owner.kind !== "coordinator")
381
382
  return null; // A stable lead is identity, not a bearer credential.
382
- const row = store.handle.prepare("SELECT repos, revoked_at FROM coordinator_credential WHERE cid = ?").get(owner.id);
383
+ const row = store.handle.prepare("SELECT projects_json AS repos, revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(owner.id);
383
384
  if (row === undefined || row["revoked_at"] !== null)
384
385
  return null;
385
386
  let repos;
@@ -3,6 +3,7 @@ import type { Store } from './store.js';
3
3
  import type { Principal } from './operate.js';
4
4
  import { type RemoteCommandLookup } from './remote-command.js';
5
5
  import { type Admission, type BudgetLimit } from './request-budget.js';
6
+ import type { SessionRunner } from './session-remote.js';
6
7
  /**
7
8
  * `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
8
9
  * server runs that same command as that person. There is no per-resource API here and no second policy: the transport
@@ -11,6 +12,10 @@ import { type Admission, type BudgetLimit } from './request-budget.js';
11
12
  *
12
13
  * Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
13
14
  * carrying a browser Origin is refused, so a page can never drive it.
15
+ *
16
+ * `session …` (D7) is the one command the server answers through its own owner rather than runOperateAs: native
17
+ * session operations need the server's live coding workspace (session-remote.ts). They are admitted, budgeted and
18
+ * refused here exactly like every other command.
14
19
  */
15
20
  /** Who a remote command runs as, and the runner: operate.ts's own types, so a change there breaks this build instead of
16
21
  * drifting. Type-only: the value is loaded lazily through `run` (operate.ts imports the server that imports this). */
@@ -45,6 +50,8 @@ export type CliHttpOptions = {
45
50
  modeOf?: RemoteCommandLookup | undefined;
46
51
  /** The token's request budget (request-budget.ts), charged once per request before its body is read. */
47
52
  admit?: ((principal: Principal) => Admission) | undefined;
53
+ /** Native session operations (`session …`), run by the server's session owner; absent where none is ready. */
54
+ session?: SessionRunner | undefined;
48
55
  store: Store;
49
56
  };
50
57
  /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
package/dist/cli-http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { adapterPolicy } from "./server/route-policy.js";
2
2
  import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
3
3
  import { limitWords } from './request-budget.js';
4
+ import { TOKEN_REPLACEMENT } from './password-bearer.js';
4
5
  /** Inlined file contents, summed as UTF-8 bytes. */
5
6
  export const CLI_FILES_BYTES = 256 * 1024;
6
7
  /** The whole JSON body: the files plus argv and JSON escaping headroom. */
@@ -8,11 +9,15 @@ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
8
9
  const ARGV_MAX = 256;
9
10
  const ARG_BYTES = 16 * 1024;
10
11
  const FILES_MAX = 16;
12
+ /** What a missing or unusable token hears: how to get one, and how to save it. */
13
+ const SIGN_IN = `Sign in with an API token: ${TOKEN_REPLACEMENT.charAt(0).toLowerCase()}${TOKEN_REPLACEMENT.slice(1)}, then toolroll connect <origin> --token-stdin.`;
11
14
  function send(response, status, value, headers = {}) {
12
15
  if (response.destroyed || response.writableEnded)
13
16
  return;
14
- response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
15
- response.end(JSON.stringify(value));
17
+ // A declared length: the client can tell a complete answer from a cut-off one.
18
+ const text = JSON.stringify(value);
19
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(Buffer.byteLength(text)), 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
20
+ response.end(text);
16
21
  }
17
22
  async function body(request) {
18
23
  const chunks = [];
@@ -83,7 +88,7 @@ export async function handleCliHttp(request, response, options) {
83
88
  if (names.includes('cookie'))
84
89
  return reject(400, 'cookie-refused', 'Remote commands sign in with an API token only.');
85
90
  if (!/^Bearer so_\S+$/.test(request.headers.authorization ?? ''))
86
- return reject(401, 'unauthenticated', 'Sign in with your API token: toolroll connect <origin> --token-stdin.');
91
+ return reject(401, 'unauthenticated', SIGN_IN);
87
92
  if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
88
93
  return reject(415, 'invalid-content-type', 'Send application/json.');
89
94
  if (Number(request.headers['content-length'] ?? 0) > CLI_REQUEST_BYTES)
@@ -96,7 +101,7 @@ export async function handleCliHttp(request, response, options) {
96
101
  return reject(503, 'authentication-unavailable', 'Sign-in could not be checked.');
97
102
  }
98
103
  if (!principal)
99
- return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
104
+ return reject(401, 'unauthenticated', `This API token is not valid (expired, revoked or its person removed). ${TOKEN_REPLACEMENT}.`);
100
105
  // Counted once here, however long the command then holds the request (a task wait is one request).
101
106
  const admitted = options.admit === undefined ? { ok: true } : options.admit(principal);
102
107
  if (!admitted.ok && admitted.status === 503)
@@ -118,6 +123,8 @@ export async function handleCliHttp(request, response, options) {
118
123
  const parsed = cliRequest(value);
119
124
  if ('problem' in parsed)
120
125
  return reject(parsed.tooLarge ? 413 : 400, 'invalid-request', parsed.problem);
126
+ if (parsed.argv[0] === 'session')
127
+ return runSession(response, options, principal, parsed);
121
128
  const row = (options.modeOf ?? contractRow)(parsed.argv);
122
129
  if (row?.mode === 'step-up')
123
130
  return reject(403, 'step-up', STEP_UP_MESSAGE);
@@ -154,3 +161,22 @@ export async function handleCliHttp(request, response, options) {
154
161
  send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
155
162
  return true;
156
163
  }
164
+ /** `session …`: the prompt is the request's one file; the session owner answers one session envelope on stdout. */
165
+ async function runSession(response, options, principal, parsed) {
166
+ const refuse = (status, code, message) => { send(response, status, { ok: false, code, message }); return true; };
167
+ if (options.session === undefined)
168
+ return refuse(501, 'remote-unavailable', 'This server cannot run session commands. Update it, or use the console.');
169
+ const at = parsed.argv.indexOf('--file'), file = at < 0 ? undefined : parsed.argv[at + 1];
170
+ if (Object.keys(parsed.files).some(path => path !== file))
171
+ return refuse(400, 'invalid-request', 'Each file must name a declared input file argument.');
172
+ const out = [];
173
+ let exitCode;
174
+ try {
175
+ exitCode = await options.session(principal, parsed.argv, parsed.files, line => { out.push(`${line}\n`); });
176
+ }
177
+ catch {
178
+ return refuse(500, 'command-failed', 'The command stopped unexpectedly on the server. Check its state before running it again.');
179
+ }
180
+ send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
181
+ return true;
182
+ }
package/dist/cli.js CHANGED
@@ -424,7 +424,8 @@ async function dispatch(argv, write, mainOptions) {
424
424
  if (first === "session") {
425
425
  // D5: native coding sessions are deprecated. They still run this release; each use says so once, on stderr.
426
426
  (mainOptions.session?.stderr ?? (line => process.stderr.write(`${line}\n`)))(deprecationWarning(`session ${rest.find(arg => !arg.startsWith("-")) ?? ""}`.trim(), "session"));
427
- return runSessionCommand(rest, write, mainOptions.session);
427
+ // D7: it runs on the saved connection over /api/cli, signed in with that profile's API token.
428
+ return runSessionCommand(rest, write, { ...(mainOptions.remote ?? mainOptions.team), ...mainOptions.session });
428
429
  }
429
430
  if (first === "demo")
430
431
  return runDemoCommand(rest, write);
@@ -2,7 +2,8 @@
2
2
  * The coordinator principal (MCP gateway spec v6; DESIGN.md §9b).
3
3
  *
4
4
  * A coordinator is the ONE credential the MCP surface accepts: minted by
5
- * an operator password ceremony, hashed at rest, repo-scoped at the mint,
5
+ * an operator password ceremony, hashed at rest (a `credential` row of kind
6
+ * 'coordinator', v118: the shared codec and table), repo-scoped at the mint,
6
7
  * rate-limited, revocable — and able to do exactly one thing: file a
7
8
  * proposal through the canonical door. What it files is an ordinary
8
9
  * UNAPPROVED task; admission is the operator's existing scope ceremony,
@@ -2,7 +2,8 @@
2
2
  * The coordinator principal (MCP gateway spec v6; DESIGN.md §9b).
3
3
  *
4
4
  * A coordinator is the ONE credential the MCP surface accepts: minted by
5
- * an operator password ceremony, hashed at rest, repo-scoped at the mint,
5
+ * an operator password ceremony, hashed at rest (a `credential` row of kind
6
+ * 'coordinator', v118: the shared codec and table), repo-scoped at the mint,
6
7
  * rate-limited, revocable — and able to do exactly one thing: file a
7
8
  * proposal through the canonical door. What it files is an ordinary
8
9
  * UNAPPROVED task; admission is the operator's existing scope ceremony,
@@ -14,7 +15,8 @@
14
15
  * the exported-cast mistake is named in the spec and not repeated here.
15
16
  * Steering and every operator-speech surface cannot accept this type.
16
17
  */
17
- import { createHash, randomBytes } from "node:crypto";
18
+ import { createHash } from "node:crypto";
19
+ import { hashSecret, mintCredential } from "./api-tokens.js";
18
20
  import { canonicalRepos } from "./runner.js";
19
21
  import { fileTaskProposal } from "./proposal.js";
20
22
  import { describeScope } from "./scope.js";
@@ -27,18 +29,7 @@ function verified(row) {
27
29
  return row;
28
30
  }
29
31
  const NAME = /^[a-z0-9-]{1,32}$/;
30
- const CID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
31
32
  const KEY = /^[\x21-\x7e]{8,64}$/;
32
- function hashSecret(token) {
33
- return createHash("sha256").update(token, "utf8").digest("hex");
34
- }
35
- function mintCid() {
36
- const bytes = randomBytes(12);
37
- let cid = "";
38
- for (const b of bytes)
39
- cid += CID_ALPHABET[b % CID_ALPHABET.length];
40
- return cid;
41
- }
42
33
  export function mintCoordinator(store, input) {
43
34
  const days = input.days ?? 90;
44
35
  if (!Number.isInteger(days) || days < 1 || days > 365)
@@ -53,15 +44,16 @@ export function mintCoordinator(store, input) {
53
44
  if (input.repos.length === 0)
54
45
  return { ok: false, reason: "no-repos" };
55
46
  const repos = canonicalRepos(input.repos);
56
- const token = (input.newToken ?? (() => randomBytes(32).toString("base64url")))();
47
+ const token = (input.newToken ?? (() => mintCredential("coordinator").token))();
57
48
  return store.transact(() => {
58
49
  for (let attempt = 0; attempt < 5; attempt += 1) {
59
- const cid = (input.newCid ?? mintCid)();
50
+ const cid = (input.newCid ?? (() => mintCredential("coordinator").id))();
60
51
  try {
52
+ // Its generation: which coordinator of this name it is (revoke-and-remint keeps the earlier one's history apart).
61
53
  store.handle
62
- .prepare(`INSERT INTO coordinator_credential (cid, name, credential_hash, repos, per_hour, created_by, created_at, expires_at)
63
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
64
- .run(cid, name, hashSecret(token), JSON.stringify(repos), perHour, input.by, input.now.toISOString(), expiresAt);
54
+ .prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, created_by, created_at, expires_at, generation)
55
+ VALUES (?, 'coordinator', ?, ?, ?, ?, 'act', ?, ?, ?, ?, ?, 1 + (SELECT count(*) FROM credential WHERE kind = 'coordinator' AND name = ?))`)
56
+ .run(cid, cid, input.by, name, hashSecret(token), JSON.stringify(repos), perHour, input.by, input.now.toISOString(), expiresAt, name);
65
57
  return { ok: true, cid, token, repos };
66
58
  }
67
59
  catch (error) {
@@ -81,9 +73,8 @@ export function mintCoordinator(store, input) {
81
73
  * the stored hash; the caller re-runs this INSIDE any mutating
82
74
  * transaction (the startup check is a courtesy; the txn check is law). */
83
75
  export function authenticateCoordinator(store, token) {
84
- const row = store.handle
85
- .prepare("SELECT * FROM coordinator_credential WHERE credential_hash = ?")
86
- .get(hashSecret(token));
76
+ const found = store.presentedCredential(token);
77
+ const row = found?.kind === "coordinator" ? store.handle.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'coordinator'").get(found.id) : undefined;
87
78
  if (row === undefined)
88
79
  return { ok: false, reason: "unknown" };
89
80
  if (row["revoked_at"] !== null)
@@ -94,23 +85,23 @@ export function authenticateCoordinator(store, token) {
94
85
  return {
95
86
  ok: true,
96
87
  who: verified({
97
- cid: String(row["cid"]),
88
+ cid: String(row["id"]),
98
89
  name: String(row["name"]),
99
- repos: JSON.parse(String(row["repos"])),
90
+ repos: JSON.parse(String(row["projects_json"])),
100
91
  perHour: Number(row["per_hour"]),
101
92
  }),
102
93
  };
103
94
  }
104
95
  export function revokeCoordinator(store, cid, by, now) {
105
96
  return store.transact(() => {
106
- const row = store.handle.prepare("SELECT revoked_at FROM coordinator_credential WHERE cid = ?").get(cid);
97
+ const row = store.handle.prepare("SELECT revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(cid);
107
98
  if (row === undefined)
108
99
  return { ok: false, reason: "unknown" };
109
100
  if (row["revoked_at"] !== null)
110
101
  return { ok: false, reason: "already-revoked" };
111
102
  store.handle
112
- .prepare("UPDATE coordinator_credential SET revoked_at = ? WHERE cid = ?")
113
- .run(now.toISOString(), cid);
103
+ .prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'")
104
+ .run(now.toISOString(), by, cid);
114
105
  // The revocation event rides the SAME transaction as the state change,
115
106
  // and the credential's pending proposals expire with it (mate arc v3).
116
107
  store.handle
@@ -122,13 +113,13 @@ export function revokeCoordinator(store, cid, by, now) {
122
113
  }
123
114
  export function listCoordinators(store) {
124
115
  return store.handle
125
- .prepare(`SELECT c.*, (SELECT MAX(e.created_at) FROM coordinator_event e WHERE e.cid = c.cid AND e.kind = 'filed') AS last_filed
126
- FROM coordinator_credential c ORDER BY c.created_at, c.cid`)
116
+ .prepare(`SELECT c.*, (SELECT MAX(e.created_at) FROM coordinator_event e WHERE e.cid = c.id AND e.kind = 'filed') AS last_filed
117
+ FROM credential c WHERE c.kind = 'coordinator' ORDER BY c.created_at, c.id`)
127
118
  .all()
128
119
  .map(row => ({
129
- cid: String(row["cid"]),
120
+ cid: String(row["id"]),
130
121
  name: String(row["name"]),
131
- repos: JSON.parse(String(row["repos"])),
122
+ repos: JSON.parse(String(row["projects_json"])),
132
123
  perHour: Number(row["per_hour"]),
133
124
  createdBy: String(row["created_by"]),
134
125
  createdAt: String(row["created_at"]),
@@ -253,7 +244,7 @@ export function fileCoordinatorProposal(store, token, input, now) {
253
244
  ],
254
245
  }),
255
246
  ...(input.deliverable === undefined ? {} : { deliverable: input.deliverable }),
256
- filedVia: `mcp:${who.name}`, filedBy: { name: store.handle.prepare("SELECT created_by FROM coordinator_credential WHERE cid = ?").get(who.cid)?.["created_by"] ?? null, kind: "coordinator" },
247
+ filedVia: `mcp:${who.name}`, filedBy: { name: store.handle.prepare("SELECT created_by FROM credential WHERE id = ? AND kind = 'coordinator'").get(who.cid)?.["created_by"] ?? null, kind: "coordinator" },
257
248
  admittedRepos: who.repos,
258
249
  }, now);
259
250
  if (!made.ok)
@@ -94,6 +94,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
+ version?: never;
97
98
  active: boolean;
98
99
  phase: string;
99
100
  detail: string;
@@ -107,7 +108,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
107
108
  wasRunning?: never;
108
109
  backupPath?: never;
109
110
  updateId?: never;
110
- version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
package/dist/export.js CHANGED
@@ -33,7 +33,7 @@ const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "chat_pair", "oa
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
- "api_token.secret_hash", "lead_credential.secret_hash", "approver.credential_hash", "coordinator_credential.credential_hash", "runner.credential_hash", "invite.token_hash",
36
+ "credential.secret_hash", "approver.credential_hash", "runner.credential_hash", "invite.token_hash",
37
37
  "flow_trigger.hook_hash", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
38
38
  "chat_turn.credential_key", "lead_session.credential_key", "mate_turn.credential_key", "chat_runtime.push_url", "workflow_preview.token",
39
39
  "quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
@@ -56,7 +56,7 @@ export function categoryOf(table) {
56
56
  [/^subagent/, "subagents"],
57
57
  [/^(flow|workflow_)/, "flows"],
58
58
  [/^(chat_|lead_|team_|notification|push_)/, "chats"],
59
- [/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_|oauth_)/, "people"],
59
+ [/^(approver|credential$|invite|sso_identity|coordinator_|oauth_)/, "people"],
60
60
  [/^(run|artifact|claim|worktree$|execution_slot|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"],
package/dist/mcp-http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { adapterPolicy } from "./server/route-policy.js";
2
2
  import { PACKAGE_VERSION } from "./version.js";
3
- import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
3
+ import { tokenLive, tokenProjects } from "./api-tokens.js";
4
4
  import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
5
5
  import { authenticateCoordinator } from "./coordinator.js";
6
6
  import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
@@ -26,10 +26,10 @@ export function createMcpHttp(options) {
26
26
  if (presented.startsWith("so_")) {
27
27
  if (!options.signedIn(request))
28
28
  return null;
29
- const parsed = parseApiToken(presented);
30
- const kept = parsed === null ? null : store.apiTokenSecret(parsed.id);
31
- // The secret is checked here too, so this door never rests on how signedIn reads the request.
32
- if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash))
29
+ // The secret is checked here too (the one credential check), so this door never rests on how signedIn reads the request.
30
+ const found = store.presentedCredential(presented);
31
+ const kept = found?.kind === "person" ? store.apiTokenSecret(found.id) : null;
32
+ if (kept === null)
33
33
  return null;
34
34
  if (!tokenLive(kept.row, options.clock().getTime()))
35
35
  return null;
package/dist/operate.js CHANGED
@@ -96,7 +96,8 @@ import { deleteProject, holdingsWords, projectHoldings, projectRunning } from ".
96
96
  import { pushPass } from "./push.js";
97
97
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
98
98
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from "./names.js";
99
- import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
99
+ import { claimActor, currentActor, withActor } from "./actor.js";
100
+ import { parseLeadToken } from "./api-tokens.js";
100
101
  import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
101
102
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
102
103
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * D7: a password on each request (`Authorization: Bearer <name>:<password>`) is deprecated. This release still
3
+ * accepts it everywhere it was accepted, with the same per-address tries, locks and budgets, but every answer carries
4
+ * a deprecation warning, and every refusal of such a request names the replacement: an API token. From
5
+ * PASSWORD_BEARER_REFUSED_FROM on, the server refuses it before checking the password.
6
+ */
7
+ import type { ServerResponse } from "node:http";
8
+ /** The one replacement every machine-credential refusal names. */
9
+ export declare const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
10
+ /** The first release that refuses a password on a request: one release after this one (0.9.58) deprecated it. */
11
+ export declare const PASSWORD_BEARER_REFUSED_FROM = "0.9.59";
12
+ export declare const PASSWORD_BEARER_WARNING = "Signing in with a password on each request is deprecated and stops working in Toolroll 0.9.59. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
13
+ /** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
14
+ export declare const PASSWORD_PROFILE_WARNING = "Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll 0.9.59. Create an API token with `toolroll tokens create`, then connect again with --token-stdin.";
15
+ export declare const PASSWORD_BEARER_REFUSED = "Toolroll no longer accepts a password on a request. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
16
+ /** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
17
+ export declare function isPasswordBearer(authorization: string | undefined): boolean;
18
+ /** Whether this build still accepts a password bearer: only a release before PASSWORD_BEARER_REFUSED_FROM does. */
19
+ export declare function passwordBearerAccepted(version?: string): boolean;
20
+ /** A refusal's words for this request: unchanged, or with the replacement when it came with a password bearer. */
21
+ export declare function withReplacement(authorization: string | undefined, message: string): string;
22
+ /**
23
+ * The refusal boundary for one password-bearer request, whichever adapter answers it: the deprecation is announced in
24
+ * headers (RFC 9745 Deprecation, and a Warning naming the replacement), and any refusal's words, plain text or a JSON
25
+ * `message`, gain the replacement. Successful answers are left byte for byte.
26
+ */
27
+ export declare function announcePasswordBearer(response: ServerResponse): void;