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.
- package/CHANGELOG.md +8 -0
- package/dist/action-ledger.js +0 -4
- package/dist/actor.d.ts +0 -10
- package/dist/actor.js +1 -18
- package/dist/api-tokens.d.ts +33 -2
- package/dist/api-tokens.js +39 -11
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.js +3 -2
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/desktop-update.d.ts +1 -1
- package/dist/export.js +2 -2
- package/dist/mcp-http.js +5 -5
- package/dist/operate.js +2 -1
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/remote-command.js +0 -2
- package/dist/remote-exec.d.ts +19 -0
- package/dist/remote-exec.js +9 -8
- package/dist/request-budget.d.ts +1 -1
- package/dist/scope.js +2 -1
- package/dist/serve.js +15 -0
- package/dist/server/edge-refusal.d.ts +2 -0
- package/dist/server/edge-refusal.js +8 -6
- package/dist/server/guards.js +5 -4
- package/dist/server/http.d.ts +2 -0
- package/dist/server/remote.js +6 -13
- package/dist/server/route-table.js +0 -3
- package/dist/session-cli.d.ts +38 -9
- package/dist/session-cli.js +141 -154
- package/dist/session-contract.js +2 -2
- package/dist/session-remote.d.ts +19 -0
- package/dist/session-remote.js +86 -0
- package/dist/store.d.ts +28 -6
- package/dist/store.js +259 -93
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/toolroll-update.js +3 -1
- package/package.json +1 -1
- package/dist/session-http.d.ts +0 -24
- package/dist/session-http.js +0 -170
- package/dist/session-server.d.ts +0 -17
- 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
|
package/dist/action-ledger.js
CHANGED
|
@@ -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
|
-
}
|
package/dist/api-tokens.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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. */
|
package/dist/api-tokens.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* request
|
|
4
|
-
* person may), always expires (a year at most), is shown once, and only its
|
|
5
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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
|
|
25
|
-
return
|
|
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
|
|
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
|
|
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"));
|
package/dist/assignment.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/cli-http.d.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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);
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -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
|
|
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,
|
package/dist/coordinator.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ?? (() =>
|
|
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 ??
|
|
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
|
|
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
|
|
85
|
-
|
|
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["
|
|
88
|
+
cid: String(row["id"]),
|
|
98
89
|
name: String(row["name"]),
|
|
99
|
-
repos: JSON.parse(String(row["
|
|
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
|
|
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
|
|
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.
|
|
126
|
-
FROM
|
|
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["
|
|
120
|
+
cid: String(row["id"]),
|
|
130
121
|
name: String(row["name"]),
|
|
131
|
-
repos: JSON.parse(String(row["
|
|
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
|
|
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)
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -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
|
-
"
|
|
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|
|
|
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 {
|
|
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
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (
|
|
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,
|
|
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;
|