toolroll 0.9.56 → 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 +16 -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-adapters.d.ts +5 -0
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.d.ts +24 -3
- package/dist/assignment.js +39 -12
- package/dist/browser-workspace.js +1 -1
- package/dist/chat-delivery.js +3 -3
- package/dist/chat-voice.js +1 -1
- package/dist/check-levels.d.ts +3 -0
- package/dist/check-levels.js +5 -0
- 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/dispose.js +0 -5
- package/dist/export.js +2 -2
- package/dist/lead-status.d.ts +1 -1
- package/dist/lead-status.js +2 -2
- package/dist/mcp-http.js +5 -5
- package/dist/operate.js +19 -9
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/proof.d.ts +9 -0
- package/dist/proof.js +12 -0
- package/dist/release-coverage.d.ts +16 -0
- package/dist/release-coverage.js +92 -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/chat.js +8 -6
- 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/render-results.d.ts +4 -1
- package/dist/server/render-results.js +23 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +28 -25
- 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 +31 -11
- package/dist/store.js +276 -95
- package/dist/task-outcome-cli.js +3 -1
- package/dist/task-status.d.ts +59 -7
- package/dist/task-status.js +132 -16
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/team-runtime.js +1 -1
- package/dist/telegram-status.d.ts +11 -4
- package/dist/telegram-status.js +17 -10
- package/dist/telegram.js +4 -4
- package/dist/toolroll-update.js +3 -1
- package/dist/work-index.d.ts +6 -1
- package/dist/work-index.js +50 -8
- package/dist/workspace-ui.js +2 -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,21 @@
|
|
|
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
|
+
|
|
11
|
+
## 0.9.57 — 2026-10-09
|
|
12
|
+
|
|
13
|
+
- **Truthful status.** Finished tasks read Complete everywhere. With checks
|
|
14
|
+
off, criteria read "checked at release" instead of Needs you.
|
|
15
|
+
- **No false finish.** Completing or deploying refuses a failed or missing
|
|
16
|
+
check, an unmet criterion, a serious review finding, or missing evidence.
|
|
17
|
+
- **Clear revisions.** Revisions are named after their task: -v2, -v3.
|
|
18
|
+
|
|
3
19
|
## 0.9.56 — 2026-10-09
|
|
4
20
|
|
|
5
21
|
- **One lead.** Your assistant in the console, on your phone, in chat and in
|
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) {
|
|
@@ -109,6 +109,11 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
|
|
|
109
109
|
deployment: {
|
|
110
110
|
status: "not-recorded";
|
|
111
111
|
};
|
|
112
|
+
readiness: {
|
|
113
|
+
verdict: import("./proof.js").ProofVerdict | null;
|
|
114
|
+
checksOff: boolean;
|
|
115
|
+
blockers: import("./task-status.js").CompletionBlocker[];
|
|
116
|
+
} | null;
|
|
112
117
|
};
|
|
113
118
|
}[];
|
|
114
119
|
nextCursor: number;
|
|
@@ -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.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type Store, type ProofVerdictRow, type ProofAcceptanceRow } from "./store.js";
|
|
2
2
|
import { type WorkAction, type WorkSummaryAccess } from "./work-summary.js";
|
|
3
3
|
import { type VerifiedApprover } from "./principal.js";
|
|
4
|
+
import { type ProofVerdict } from "./proof.js";
|
|
4
5
|
import { type CheckLevel } from "./check-levels.js";
|
|
5
6
|
import { type BuildReviewView } from "./review-switch.js";
|
|
6
7
|
import { type NeedKey, type WaitKey } from "./needs-you.js";
|
|
7
|
-
import { type ChecksBatch } from "./task-status.js";
|
|
8
|
+
import { type ChecksBatch, type CompletionBlocker } from "./task-status.js";
|
|
9
|
+
import { type ReleaseCoverage, type ReleaseMemo } from "./release-coverage.js";
|
|
8
10
|
import { type LeadClaim } from "./lead-voice.js";
|
|
9
11
|
export type AssignmentAccess = WorkSummaryAccess;
|
|
10
12
|
export type AssignmentOwner = {
|
|
@@ -24,6 +26,8 @@ export type AssignmentChecks = {
|
|
|
24
26
|
running?: "quick" | "full" | null;
|
|
25
27
|
/** A batch check (batch-checks.ts): waiting for its batch, or how it was checked. */
|
|
26
28
|
batch?: ChecksBatch | null;
|
|
29
|
+
/** Checks Off: the passing full release check that contains this commit (release-coverage.ts), read now, never sealed. */
|
|
30
|
+
release?: ReleaseCoverage | null;
|
|
27
31
|
};
|
|
28
32
|
export type AssignmentReceipt = {
|
|
29
33
|
digest: string;
|
|
@@ -118,6 +122,13 @@ export type AssignmentSnapshot = {
|
|
|
118
122
|
earlierActive?: number;
|
|
119
123
|
/** Of those, how many are running rather than only queued; present with earlierActive. */
|
|
120
124
|
earlierRunning?: number;
|
|
125
|
+
/** The result as it reads today (task-status.ts): the verdict it shows, whether its checks were deliberately Off, and
|
|
126
|
+
* what refuses Complete. Derived at read time; never part of the sealed receipt. Null without a result. */
|
|
127
|
+
readiness?: {
|
|
128
|
+
verdict: ProofVerdict | null;
|
|
129
|
+
checksOff: boolean;
|
|
130
|
+
blockers: CompletionBlocker[];
|
|
131
|
+
} | null;
|
|
121
132
|
};
|
|
122
133
|
/** Status-first handoff for routine reads. Fetch get_assignment only when
|
|
123
134
|
* inspecting its exact evidence; do not repeat a full proof in every poll. */
|
|
@@ -169,9 +180,14 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
|
|
|
169
180
|
deployment: {
|
|
170
181
|
status: "not-recorded";
|
|
171
182
|
};
|
|
183
|
+
readiness: {
|
|
184
|
+
verdict: ProofVerdict | null;
|
|
185
|
+
checksOff: boolean;
|
|
186
|
+
blockers: CompletionBlocker[];
|
|
187
|
+
} | null;
|
|
172
188
|
} | null;
|
|
173
189
|
export declare function assignmentChecksForRun(store: Store, root: string | undefined, runId: number, now: Date): AssignmentChecks;
|
|
174
|
-
export declare function assignmentOf(store: Store, taskId: string, now: Date, access: AssignmentAccess, root?: string): AssignmentSnapshot | null;
|
|
190
|
+
export declare function assignmentOf(store: Store, taskId: string, now: Date, access: AssignmentAccess, root?: string, memo?: ReleaseMemo): AssignmentSnapshot | null;
|
|
175
191
|
type MutationResult = {
|
|
176
192
|
ok: true;
|
|
177
193
|
assignment: AssignmentSnapshot;
|
|
@@ -203,7 +219,7 @@ type Finished = {
|
|
|
203
219
|
* (no reason needed), or an exception a report that doesn't match its changes needs a reason for. Null when
|
|
204
220
|
* Accept and finish is a plain completion: nothing is owed (no proof, a failed check, or a verified result),
|
|
205
221
|
* or it was already accepted. */
|
|
206
|
-
export declare function owedAcceptance(receipt: AssignmentReceipt | null): "person-check" | "exception" | null;
|
|
222
|
+
export declare function owedAcceptance(receipt: AssignmentReceipt | null, blockers?: readonly CompletionBlocker[]): "person-check" | "exception" | null;
|
|
207
223
|
/** Accept and finish: the person's acceptance of the exact result they read and its completion, in one
|
|
208
224
|
* transaction. Whether an acceptance is owed is decided here, against the receipt as it stands: only a
|
|
209
225
|
* person's own check or a reasoned exception records one (with its ledger act); an exception without its
|
|
@@ -278,6 +294,11 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
|
|
|
278
294
|
deployment: {
|
|
279
295
|
status: "not-recorded";
|
|
280
296
|
};
|
|
297
|
+
readiness: {
|
|
298
|
+
verdict: ProofVerdict | null;
|
|
299
|
+
checksOff: boolean;
|
|
300
|
+
blockers: CompletionBlocker[];
|
|
301
|
+
} | null;
|
|
281
302
|
};
|
|
282
303
|
}[];
|
|
283
304
|
nextCursor: number;
|
package/dist/assignment.js
CHANGED
|
@@ -14,13 +14,14 @@ import { verificationEvidence } from "./verification-evidence.js";
|
|
|
14
14
|
import { reproveApprover } from "./principal.js";
|
|
15
15
|
import { noteAssignmentStatus } from "./assignment-status.js";
|
|
16
16
|
import { historicalAssessmentReason } from "./assignment-presentation.js";
|
|
17
|
-
import { manualReviewOnly } from "./proof.js";
|
|
17
|
+
import { manualReviewOnly, shownVerdictOf } from "./proof.js";
|
|
18
18
|
import { ACCEPT_NEEDS_REASON, cantAcceptYetOf } from "./result-acts.js";
|
|
19
|
-
import { runCheckLevel } from "./check-levels.js";
|
|
19
|
+
import { requiredCheckCommandFor, runCheckLevel } from "./check-levels.js";
|
|
20
20
|
import { followUpChecksOf, withFollowUps } from "./result-follow-ups.js";
|
|
21
21
|
import { buildReviewOf, findingWords } from "./review-switch.js";
|
|
22
22
|
import { NEEDS, WAITS, processNeedOf, resultHoldUpSentence } from "./needs-you.js";
|
|
23
|
-
import { assignmentStageOf } from "./task-status.js";
|
|
23
|
+
import { assignmentStageOf, checkBackingOf, completionBlockersOf, requirementWordOf } from "./task-status.js";
|
|
24
|
+
import { releaseCoverageOf } from "./release-coverage.js";
|
|
24
25
|
import { leadClaimOf } from "./lead-voice.js";
|
|
25
26
|
/** Status-first handoff for routine reads. Fetch get_assignment only when
|
|
26
27
|
* inspecting its exact evidence; do not repeat a full proof in every poll. */
|
|
@@ -35,7 +36,7 @@ export function assignmentBrief(assignment) {
|
|
|
35
36
|
base: receipt.base, head: receipt.head, completionKind: receipt.completionKind,
|
|
36
37
|
checks: receipt.checks, proofAcceptance: receipt.proofAcceptance, verdict: receipt.proof?.verdict ?? null,
|
|
37
38
|
criteria: { passed: receipt.proof?.matrix.filter(row => row.state === "pass").length ?? 0, total: receipt.proof?.matrix.length ?? 0 }, evidence: receipt.evidence },
|
|
38
|
-
publication: assignment.publication, review: assignment.review, deployment: assignment.deployment };
|
|
39
|
+
publication: assignment.publication, review: assignment.review, deployment: assignment.deployment, readiness: assignment.readiness ?? null };
|
|
39
40
|
}
|
|
40
41
|
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
41
42
|
/** A lookup added with lead-quiet, read safely: a deploy proves completion with this code over the INSTALLED runtime's
|
|
@@ -51,7 +52,7 @@ function olderStoreSafe(read, absent) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
/** A check as the receipt digest seals it: presentation-only fields (check-levels.ts) left out. */
|
|
54
|
-
const sealedChecks = ({ level: _level, running: _running, batch: _batch, ...proof }) => proof;
|
|
55
|
+
const sealedChecks = ({ level: _level, running: _running, batch: _batch, release: _release, ...proof }) => proof;
|
|
55
56
|
const OWNER_ACTION = "assignment claimed";
|
|
56
57
|
const CHECK_ACTION = COMPLETION_ACTION;
|
|
57
58
|
const actorOf = (owner) => `${owner.kind}:${owner.id}`;
|
|
@@ -72,7 +73,8 @@ function ownerOf(store, rootId, repo) {
|
|
|
72
73
|
const id = claim === undefined ? origin : String(claim["actor"]).replace(/^coordinator:/, "");
|
|
73
74
|
if (!id)
|
|
74
75
|
return null;
|
|
75
|
-
|
|
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);
|
|
76
78
|
let repos = [];
|
|
77
79
|
try {
|
|
78
80
|
repos = JSON.parse(String(credential?.["repos"] ?? "[]"));
|
|
@@ -127,7 +129,7 @@ function buildChecks(store, root, runId) {
|
|
|
127
129
|
return unavailable("Saved checks could not be read.");
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
|
-
export function assignmentOf(store, taskId, now, access, root) {
|
|
132
|
+
export function assignmentOf(store, taskId, now, access, root, memo) {
|
|
131
133
|
const family = store.taskFamilyOf(taskId, access.repos, access.principal === "operator" && access.includeUnplaced === true);
|
|
132
134
|
if (family === null)
|
|
133
135
|
return null;
|
|
@@ -163,7 +165,10 @@ export function assignmentOf(store, taskId, now, access, root) {
|
|
|
163
165
|
const reports = artifacts.filter(a => a.kind === "report");
|
|
164
166
|
const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
|
|
165
167
|
.map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
|
|
166
|
-
const
|
|
168
|
+
const ownChecks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
|
|
169
|
+
// Checks Off: a passing full release check that contains this commit covers it, read now from the existing receipts.
|
|
170
|
+
const checks = ownChecks === null || ownChecks.level !== "off" || ownChecks.status === "passed" || ownChecks.status === "failed" ? ownChecks
|
|
171
|
+
: { ...ownChecks, release: releaseCoverageOf(store, root, current.repo, result.headRevision, memo === undefined ? {} : { memo }) };
|
|
167
172
|
const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
|
|
168
173
|
result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
|
|
169
174
|
// Finished work returns to its lead or user. Strict terms and previous model
|
|
@@ -339,6 +344,7 @@ export function assignmentOf(store, taskId, now, access, root) {
|
|
|
339
344
|
attention, receipt: receipt?.digest ?? null }), acknowledged: false } : null;
|
|
340
345
|
const publication = result === null ? null : store.publicationForRun(result.id);
|
|
341
346
|
const lead = state === "complete" || state === "cancelled" || access.principal !== "operator" ? null : leadClaimOf(store, family.root.id, now, access.viewer);
|
|
347
|
+
const readiness = receipt === null ? null : readinessOf(store, receipt, current.repo, review);
|
|
342
348
|
// Existing saved inputs and output, read only after family admission. Keep
|
|
343
349
|
// polling briefs small; full reads disclose exactly which excerpts are shortened.
|
|
344
350
|
const planId = result?.planRevision == null ? null : store.getPlanRevision(result.planRevision)?.artifact;
|
|
@@ -355,12 +361,26 @@ export function assignmentOf(store, taskId, now, access, root) {
|
|
|
355
361
|
return { version: 1, rootId: family.root.id, activeTaskId: current.id, repo: current.repo, title: family.root.title,
|
|
356
362
|
state, detail, primaryAction, attention: [...new Set(attention)], attempts, owner, receipt, savedContext, completion, handoff,
|
|
357
363
|
publication: publication === null ? null : { state: publication.state, prUrl: publication.prUrl, remoteState: publication.remoteState },
|
|
358
|
-
review, deployment: { status: "not-recorded" }, ...(need === null ? {} : { need }), ...(lead === null ? {} : { lead }), ...(earlierActive.length === 0 ? {} : { earlierActive: earlierActive.length, earlierRunning }) };
|
|
364
|
+
review, deployment: { status: "not-recorded" }, readiness, ...(need === null ? {} : { need }), ...(lead === null ? {} : { lead }), ...(earlierActive.length === 0 ? {} : { earlierActive: earlierActive.length, earlierRunning }) };
|
|
365
|
+
}
|
|
366
|
+
/** The result's shown verdict and completion blockers (task-status.ts), from the receipt, the project's approved check
|
|
367
|
+
* and the automatic review. A HIGH finding a revision already took is resolved there. */
|
|
368
|
+
function readinessOf(store, receipt, repo, review) {
|
|
369
|
+
const report = receipt.completionKind === "research-report";
|
|
370
|
+
const high = review === null || review.sentBackAs !== null || review.state !== "reviewed" ? 0 : review.high.length;
|
|
371
|
+
const checkRequired = olderStoreSafe(() => requiredCheckCommandFor(store, repo, receipt.taskId, receipt.checks.level), null) !== null;
|
|
372
|
+
const matrix = receipt.proof?.matrix ?? [];
|
|
373
|
+
const backing = checkBackingOf(receipt.checks);
|
|
374
|
+
const verdict = shownVerdictOf(receipt.proof?.verdict ?? null, { checkFailed: !report && receipt.checks.status === "failed", highFindings: high,
|
|
375
|
+
reviewPending: review?.state === "pending", criteriaPassed: matrix.every(row => requirementWordOf(row, null, backing) === "Met") });
|
|
376
|
+
const blockers = completionBlockersOf({ report, checks: receipt.checks, checkRequired, matrix, verdict: receipt.proof?.verdict ?? null,
|
|
377
|
+
accepted: receipt.proofAcceptance !== null, high });
|
|
378
|
+
return { verdict, checksOff: !report && receipt.checks.level === "off", blockers };
|
|
359
379
|
}
|
|
360
380
|
function admittedOwner(store, taskId, owner, now, root) {
|
|
361
381
|
if (owner.kind !== "coordinator")
|
|
362
382
|
return null; // A stable lead is identity, not a bearer credential.
|
|
363
|
-
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);
|
|
364
384
|
if (row === undefined || row["revoked_at"] !== null)
|
|
365
385
|
return null;
|
|
366
386
|
let repos;
|
|
@@ -417,6 +437,10 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
417
437
|
if (current.state !== "ready-to-check" && current.state !== "complete")
|
|
418
438
|
return { ok: false, reason: "not-ready", message: "This assignment still has unresolved execution, scope or decisions." };
|
|
419
439
|
if (current.state !== "complete") {
|
|
440
|
+
// The one readiness guard: a failed or missing required check, an unresolved requirement or HIGH finding refuses.
|
|
441
|
+
const blockers = current.readiness?.blockers ?? [];
|
|
442
|
+
if (blockers.length > 0)
|
|
443
|
+
return { ok: false, reason: "not-ready", message: blockers.map(one => one.message).join(" ") };
|
|
420
444
|
// v102: declared paths are only a promise. A result whose actual diff reaches protected files, on a
|
|
421
445
|
// scope one person approved, completes only by someone else — two people have then seen the work.
|
|
422
446
|
const problem = store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root), actor.startsWith("operator:") ? actor.slice("operator:".length) : null);
|
|
@@ -476,11 +500,14 @@ class Undone extends Error {
|
|
|
476
500
|
* (no reason needed), or an exception a report that doesn't match its changes needs a reason for. Null when
|
|
477
501
|
* Accept and finish is a plain completion: nothing is owed (no proof, a failed check, or a verified result),
|
|
478
502
|
* or it was already accepted. */
|
|
479
|
-
export function owedAcceptance(receipt) {
|
|
503
|
+
export function owedAcceptance(receipt, blockers = []) {
|
|
480
504
|
if (receipt === null || receipt.proofAcceptance !== null)
|
|
481
505
|
return null;
|
|
482
506
|
if (personCheckPending(receipt))
|
|
483
507
|
return "person-check";
|
|
508
|
+
// An unresolved requirement is resolved only by an acceptance that says why.
|
|
509
|
+
if (blockers.some(one => one.key === "criteria"))
|
|
510
|
+
return "exception";
|
|
484
511
|
return cantAcceptYetOf(receipt.proof?.verdict ?? null, receipt.proof?.reasons ?? [], false) === ACCEPT_NEEDS_REASON ? "exception" : null;
|
|
485
512
|
}
|
|
486
513
|
/** Accept and finish: the person's acceptance of the exact result they read and its completion, in one
|
|
@@ -505,7 +532,7 @@ export function acceptAndCompleteAsOperator(store, taskId, input, who, now, root
|
|
|
505
532
|
return { ok: false, reason: "stale", message: "This result changed. Open the current result before accepting it." };
|
|
506
533
|
}
|
|
507
534
|
// Already complete, or nothing owed: the plain completion of the receipt as read.
|
|
508
|
-
const owed = before.state === "ready-to-check" ? owedAcceptance(receipt) : null;
|
|
535
|
+
const owed = before.state === "ready-to-check" ? owedAcceptance(receipt, before.readiness?.blockers ?? []) : null;
|
|
509
536
|
// An exception is accepted only with its reason: never a completion that skips it.
|
|
510
537
|
if (owed === "exception" && input.note === null)
|
|
511
538
|
return { ok: false, reason: "needs-reason", message: `${ACCEPT_NEEDS_REASON}.` };
|
|
@@ -19,7 +19,7 @@ export function browserCrewOf(store, now, access, options = {}) {
|
|
|
19
19
|
return { crew: [], crewTruncated: false };
|
|
20
20
|
const limit = Number.isFinite(options.limit)
|
|
21
21
|
? Math.max(1, Math.min(BROWSER_CREW_LIMIT, Math.floor(options.limit))) : BROWSER_CREW_LIMIT;
|
|
22
|
-
const page = workIndexPage(store, now, access, { limit, project });
|
|
22
|
+
const page = workIndexPage(store, now, access, { limit, project, ...(options.evidenceRoot === undefined ? {} : { root: options.evidenceRoot }) });
|
|
23
23
|
return browserCrewFromIndex(page);
|
|
24
24
|
}
|
|
25
25
|
/** Navigation input must already be admitted by the caller. No extra project
|
package/dist/chat-delivery.js
CHANGED
|
@@ -232,9 +232,9 @@ export async function processChatEvent(options) {
|
|
|
232
232
|
if (command.kind === "help")
|
|
233
233
|
state.plan(event.id, [{ text: PHONE_HELP }], now_);
|
|
234
234
|
else if (command.kind === "status")
|
|
235
|
-
state.plan(event.id, [{ text: phoneStatus(store, repos, now_, focusedTitle) }], now_);
|
|
235
|
+
state.plan(event.id, [{ text: phoneStatus(store, repos, now_, focusedTitle, options.evidenceRoot) }], now_);
|
|
236
236
|
else if (command.kind === "tasks")
|
|
237
|
-
state.plan(event.id, [{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_), focusedTitle) }], now_);
|
|
237
|
+
state.plan(event.id, [{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_, null, 8, options.evidenceRoot), focusedTitle) }], now_);
|
|
238
238
|
else if (command.kind === "lead") {
|
|
239
239
|
store.setChatFocus(surface, binding.id, null, now_);
|
|
240
240
|
state.plan(event.id, [{ text: PHONE_BACK_TO_LEAD }], now_);
|
|
@@ -243,7 +243,7 @@ export async function processChatEvent(options) {
|
|
|
243
243
|
const pick = resolvePhoneTask(store, repos, now_, command.id);
|
|
244
244
|
if (pick.kind === "one") {
|
|
245
245
|
store.setChatFocus(surface, binding.id, pick.id, now_);
|
|
246
|
-
const view = phoneTaskView(store, repos, pick.view, now_);
|
|
246
|
+
const view = phoneTaskView(store, repos, pick.view, now_, options.evidenceRoot);
|
|
247
247
|
// The status names its task (and result): a reply to it is about that task.
|
|
248
248
|
state.plan(event.id, [{ text: phoneFocusText(view.text), ...(view.link === null ? {} : { link: view.link }), task: pick.view, ...(view.run === null ? {} : { run: view.run }) }], now_);
|
|
249
249
|
}
|
package/dist/chat-voice.js
CHANGED
|
@@ -69,7 +69,7 @@ export function finishedLine(fact) {
|
|
|
69
69
|
if (fact.checks === "passed")
|
|
70
70
|
return `${ready}. Your tests passed. ${fact.pullRequest === true ? "Merge it?" : "Accept and finish it?"}`;
|
|
71
71
|
if (fact.checks === "off")
|
|
72
|
-
return `${ready}.
|
|
72
|
+
return `${ready}. Its checks run at release, so look it over first.`;
|
|
73
73
|
return `${ready}, but no tests ran. Look it over first.`;
|
|
74
74
|
case "Failed":
|
|
75
75
|
if (fact.leadOnIt === true)
|
package/dist/check-levels.d.ts
CHANGED
|
@@ -52,6 +52,9 @@ export declare function checkCommandFor(store: Store, repo: string, level: Check
|
|
|
52
52
|
level: CheckLevel;
|
|
53
53
|
command: VerifyCommand | null;
|
|
54
54
|
};
|
|
55
|
+
/** Readiness uses the result's level, falling back to the task/project for legacy
|
|
56
|
+
* runs. Quick's approved command (or its Full fallback) is required; Off is not. */
|
|
57
|
+
export declare function requiredCheckCommandFor(store: Store, repo: string | null, taskId: string, level: CheckLevel | null | undefined): VerifyCommand | null;
|
|
55
58
|
/** The builder records which level a run used, so its result says it honestly. */
|
|
56
59
|
export declare function recordRunCheckLevel(store: Store, run: {
|
|
57
60
|
id: number;
|
package/dist/check-levels.js
CHANGED
|
@@ -118,6 +118,11 @@ export function checkCommandFor(store, repo, level) {
|
|
|
118
118
|
}
|
|
119
119
|
return { level: "full", command: store.liveVerifyCommand(repo) };
|
|
120
120
|
}
|
|
121
|
+
/** Readiness uses the result's level, falling back to the task/project for legacy
|
|
122
|
+
* runs. Quick's approved command (or its Full fallback) is required; Off is not. */
|
|
123
|
+
export function requiredCheckCommandFor(store, repo, taskId, level) {
|
|
124
|
+
return repo === null ? null : checkCommandFor(store, repo, level ?? effectiveCheckLevel(store, repo, taskId).level).command;
|
|
125
|
+
}
|
|
121
126
|
/** The builder records which level a run used, so its result says it honestly. */
|
|
122
127
|
export function recordRunCheckLevel(store, run, level, from, now) {
|
|
123
128
|
store.recordAction({ at: now.toISOString(), actor: "system", repo: run.repo, taskId: run.taskId, runId: run.id, action: RUN_LEVEL_ACTION, outcome: level, source: "work",
|
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
|
+
}
|