toolroll 0.9.62 → 0.9.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +3 -0
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +3 -2
- package/dist/landed-cleanup.js +12 -5
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +7 -1
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +4 -0
- package/dist/remote-main.js +3 -3
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +11 -3
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +12 -5
- package/dist/storage-sweep-process.js +23 -6
- package/dist/store.d.ts +38 -6
- package/dist/store.js +148 -15
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +6 -4
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +3 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +10 -4
- package/dist/worktree.js +70 -29
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/store.d.ts
CHANGED
|
@@ -110,6 +110,10 @@ export declare const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the s
|
|
|
110
110
|
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
111
111
|
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
112
112
|
export declare const INTEGRATIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS integration_check (\n key TEXT PRIMARY KEY,\n outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),\n account TEXT,\n detail TEXT,\n problem TEXT,\n checked_at TEXT NOT NULL,\n ok_at TEXT,\n error TEXT,\n error_at TEXT\n);\n";
|
|
113
|
+
/** Tool secrets (no version bump): which sealing key Toolroll made for the secrets it keeps in the keychain, as a
|
|
114
|
+
* digest of the key, never the key. A key found beside them that isn't this one was put there by something else and is
|
|
115
|
+
* refused (tool-secret-store.ts). */
|
|
116
|
+
export declare const TOOL_SECRET_KEY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS tool_secret_key (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n digest TEXT NOT NULL CHECK (length(digest) = 64),\n made_at TEXT NOT NULL\n);\n-- Where secrets were kept before Toolroll's folder, decided once at this database's first start (null: none) and never\n-- probed again, so a folder a build makes later under an old name is never migrated in.\nCREATE TABLE IF NOT EXISTS tool_secret_legacy (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n dir TEXT,\n decided_at TEXT NOT NULL\n);\n";
|
|
113
117
|
export type BackupSettings = {
|
|
114
118
|
enabled: boolean;
|
|
115
119
|
everyHours: number;
|
|
@@ -166,7 +170,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
166
170
|
hash: string;
|
|
167
171
|
} | null;
|
|
168
172
|
};
|
|
169
|
-
export declare const SCHEMA_VERSION =
|
|
173
|
+
export declare const SCHEMA_VERSION = 123;
|
|
170
174
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
171
175
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
172
176
|
/**
|
|
@@ -227,7 +231,8 @@ export declare const V115_DROPPED_TABLES: readonly string[];
|
|
|
227
231
|
* three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
|
|
228
232
|
* new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
|
|
229
233
|
* table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
|
|
230
|
-
* same way.
|
|
234
|
+
* same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
|
|
235
|
+
* 0 or null), checked the same way.
|
|
231
236
|
*/
|
|
232
237
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
233
238
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
@@ -263,6 +268,13 @@ export declare const V122_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
|
|
|
263
268
|
definition: string;
|
|
264
269
|
saved: string;
|
|
265
270
|
}>>>>;
|
|
271
|
+
/** v123: the columns it adds, with the value every row saved before it reads: which authority armed a plan's
|
|
272
|
+
* unchanged-plan approval (saved rows read the operating mode), whether a trigger's firing was the clock's own (saved
|
|
273
|
+
* firings read not), and what code an on-time script firing a grant signed for ran (saved firings read none). */
|
|
274
|
+
export declare const V123_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
|
|
275
|
+
definition: string;
|
|
276
|
+
saved: string;
|
|
277
|
+
}>>>>;
|
|
266
278
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
267
279
|
export declare function updateSafeSchema(version: number | null): boolean;
|
|
268
280
|
export type OAuthClientRow = {
|
|
@@ -1770,6 +1782,18 @@ export declare class Store {
|
|
|
1770
1782
|
/** The database's own file (null in memory): where the agent fence begins. */
|
|
1771
1783
|
databaseFile(): string | null;
|
|
1772
1784
|
close(): void;
|
|
1785
|
+
/** The digest of the sealing key Toolroll made for its tool secrets; null before it made one (or in an older database). */
|
|
1786
|
+
toolSecretKeyDigest(): string | null;
|
|
1787
|
+
/**
|
|
1788
|
+
* The folder secrets were kept in before Toolroll's own, as decided at this database's first start of a command that
|
|
1789
|
+
* can start builds (`record`): `probe` runs until then, and its answer (null: none) is kept for every later start.
|
|
1790
|
+
* Other commands never write, so a preview stays read-only and a busy database doesn't stop them.
|
|
1791
|
+
*/
|
|
1792
|
+
toolSecretLegacyDir(probe: () => string | null, now: Date, options: {
|
|
1793
|
+
record: boolean;
|
|
1794
|
+
}): string | null;
|
|
1795
|
+
/** Record a new sealing key's digest only if the one recorded is still `expected` (null: none yet). */
|
|
1796
|
+
swapToolSecretKeyDigest(expected: string | null, next: string, now: Date): boolean;
|
|
1773
1797
|
/**
|
|
1774
1798
|
* Creating a task also creates its overlay reference, because a task with no
|
|
1775
1799
|
* `TaskRef` cannot be claimed, held, or scheduled — it would be work the
|
|
@@ -2164,7 +2188,7 @@ export declare class Store {
|
|
|
2164
2188
|
* exactly what the signed digest was bound to. Returns false when the
|
|
2165
2189
|
* scope vanished mid-ceremony. */
|
|
2166
2190
|
sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
|
|
2167
|
-
kind: "mode" | "chat";
|
|
2191
|
+
kind: "mode" | "chat" | "grant";
|
|
2168
2192
|
modeDigest: string;
|
|
2169
2193
|
}, approverKind?: ApproverKind): boolean;
|
|
2170
2194
|
/** Whether this task's scope carries a LIVE seal: approved, and the
|
|
@@ -2219,6 +2243,10 @@ export declare class Store {
|
|
|
2219
2243
|
* ceremony. Reconciliation drops mode-basis intents to waiting-human;
|
|
2220
2244
|
* `firing` rows are untouched — an issued call is one-shot (E1). */
|
|
2221
2245
|
revokeMode(repo: string, by: string, reason: string, now: Date): boolean;
|
|
2246
|
+
/** A flow's "runs on its own" grant ended (revoked, expired, renewed): the undispatched work it sealed falls back
|
|
2247
|
+
* to a person, as a mode's does, and plans it armed lose their pre-authorization. Running and finished work keeps
|
|
2248
|
+
* its history. */
|
|
2249
|
+
withdrawGrantApprovals(digest: string, now: Date): void;
|
|
2222
2250
|
/** F2/R-REVOKE: the ONE reconciliation road. newMode null = no live
|
|
2223
2251
|
* automerge authority (revocation, expiry, or a notify posture):
|
|
2224
2252
|
* mode-basis pending/claimed intents drop to waiting-human, leases
|
|
@@ -4412,6 +4440,10 @@ export declare class Store {
|
|
|
4412
4440
|
* other class is observational.
|
|
4413
4441
|
*/
|
|
4414
4442
|
stampTerminalClass(id: number, authMode: "subscription" | "api-key", terminalClass: TerminalClass): void;
|
|
4443
|
+
/** A launch refused before any model turn (an installed CLI too old for a
|
|
4444
|
+
* build's flags): the start stamp goes, its version and evidence stay. Only
|
|
4445
|
+
* while the run is open, so a settled run's account never changes. */
|
|
4446
|
+
withdrawProviderStart(id: number): void;
|
|
4415
4447
|
/** The race-refusal stamp (Phase 3 C5): the version that was REFUSED,
|
|
4416
4448
|
* recorded without provider_started_at — no provider process started. */
|
|
4417
4449
|
stampProviderVersion(id: number, providerVersion: string): void;
|
|
@@ -5684,7 +5716,7 @@ export declare class Store {
|
|
|
5684
5716
|
}, now: Date): number;
|
|
5685
5717
|
/** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
|
|
5686
5718
|
approveFlowScript(repo: string, name: string): boolean;
|
|
5687
|
-
removeFlowScript(repo: string, name: string): boolean;
|
|
5719
|
+
removeFlowScript(repo: string, name: string, by?: string, now?: Date): boolean;
|
|
5688
5720
|
/** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
|
|
5689
5721
|
claimFlowStep(step: {
|
|
5690
5722
|
card: number;
|
|
@@ -5764,7 +5796,7 @@ export declare class Store {
|
|
|
5764
5796
|
lastOutcome?: string;
|
|
5765
5797
|
failures?: number;
|
|
5766
5798
|
hookHash?: string | null;
|
|
5767
|
-
}, now: Date): void;
|
|
5799
|
+
}, now: Date, by?: string): void;
|
|
5768
5800
|
/** Whether a trigger already handled this outside thing (an issue, a run, a schedule slot). */
|
|
5769
5801
|
flowTriggerSaw(trigger: number, key: string): boolean;
|
|
5770
5802
|
/** The card one outside thing made (v89: a chat message, so replies in its thread find it). */
|
|
@@ -5775,7 +5807,7 @@ export declare class Store {
|
|
|
5775
5807
|
* Record what a trigger did with one outside thing: the card it made, or why it made none, and (a standing order's
|
|
5776
5808
|
* firing) the task it filed. False when it was already recorded.
|
|
5777
5809
|
*/
|
|
5778
|
-
recordFlowTriggerEvent(trigger: number, key: string, card: number | null, note: string | null, now: Date, task?: string | null): boolean;
|
|
5810
|
+
recordFlowTriggerEvent(trigger: number, key: string, card: number | null, note: string | null, now: Date, task?: string | null, onTime?: boolean, ranScript?: string | null): boolean;
|
|
5779
5811
|
/** The last card a trigger made, if any. */
|
|
5780
5812
|
lastFlowTriggerCard(trigger: number): number | null;
|
|
5781
5813
|
/**
|
package/dist/store.js
CHANGED
|
@@ -74,9 +74,10 @@ import { isProviderId } from "./provider.js";
|
|
|
74
74
|
import { authenticate as runnerAuthenticate } from "./runner.js";
|
|
75
75
|
import { stampNewProjectLevel } from "./check-levels.js";
|
|
76
76
|
import { parseCheckProgressSnapshot } from "./check-progress.js";
|
|
77
|
-
import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
|
|
77
|
+
import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
|
|
78
78
|
import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
|
|
79
79
|
import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
|
|
80
|
+
import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
|
|
80
81
|
import { RECIPE_SCHEMA } from "./recipes.js";
|
|
81
82
|
import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
|
|
82
83
|
import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
|
|
@@ -475,6 +476,23 @@ CREATE TABLE IF NOT EXISTS integration_check (
|
|
|
475
476
|
error_at TEXT
|
|
476
477
|
);
|
|
477
478
|
`;
|
|
479
|
+
/** Tool secrets (no version bump): which sealing key Toolroll made for the secrets it keeps in the keychain, as a
|
|
480
|
+
* digest of the key, never the key. A key found beside them that isn't this one was put there by something else and is
|
|
481
|
+
* refused (tool-secret-store.ts). */
|
|
482
|
+
export const TOOL_SECRET_KEY_SCHEMA = `
|
|
483
|
+
CREATE TABLE IF NOT EXISTS tool_secret_key (
|
|
484
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
485
|
+
digest TEXT NOT NULL CHECK (length(digest) = 64),
|
|
486
|
+
made_at TEXT NOT NULL
|
|
487
|
+
);
|
|
488
|
+
-- Where secrets were kept before Toolroll's folder, decided once at this database's first start (null: none) and never
|
|
489
|
+
-- probed again, so a folder a build makes later under an old name is never migrated in.
|
|
490
|
+
CREATE TABLE IF NOT EXISTS tool_secret_legacy (
|
|
491
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
492
|
+
dir TEXT,
|
|
493
|
+
decided_at TEXT NOT NULL
|
|
494
|
+
);
|
|
495
|
+
`;
|
|
478
496
|
/** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
|
|
479
497
|
* folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
|
|
480
498
|
* and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
|
|
@@ -624,7 +642,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
624
642
|
// v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
|
|
625
643
|
// remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
|
|
626
644
|
// fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
|
|
627
|
-
|
|
645
|
+
// v123 adds, and changes no saved row: flows that run on their own (flow_script_provenance, flow_unattended_grant,
|
|
646
|
+
// flow_unattended_run, plan_authorization.basis, flow_trigger_event.on_time and ran_script) and the tool-secret
|
|
647
|
+
// sealing key's record (tool_secret_key, tool_secret_legacy).
|
|
648
|
+
export const SCHEMA_VERSION = 123;
|
|
628
649
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
629
650
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
630
651
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -751,9 +772,10 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
751
772
|
* three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
|
|
752
773
|
* new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
|
|
753
774
|
* table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
|
|
754
|
-
* same way.
|
|
775
|
+
* same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
|
|
776
|
+
* 0 or null), checked the same way.
|
|
755
777
|
*/
|
|
756
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]);
|
|
778
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123]);
|
|
757
779
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
758
780
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
759
781
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -804,6 +826,16 @@ export const V121_ADDED_COLUMNS = Object.freeze({
|
|
|
804
826
|
export const V122_ADDED_COLUMNS = Object.freeze({
|
|
805
827
|
project: { remote_url: { definition: "TEXT", saved: "NULL" } },
|
|
806
828
|
});
|
|
829
|
+
/** v123: the columns it adds, with the value every row saved before it reads: which authority armed a plan's
|
|
830
|
+
* unchanged-plan approval (saved rows read the operating mode), whether a trigger's firing was the clock's own (saved
|
|
831
|
+
* firings read not), and what code an on-time script firing a grant signed for ran (saved firings read none). */
|
|
832
|
+
export const V123_ADDED_COLUMNS = Object.freeze({
|
|
833
|
+
plan_authorization: { basis: { definition: "TEXT NOT NULL DEFAULT 'mode' CHECK (basis IN ('mode', 'grant'))", saved: "'mode'" } },
|
|
834
|
+
flow_trigger_event: {
|
|
835
|
+
on_time: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
|
|
836
|
+
ran_script: { definition: "TEXT", saved: "NULL" },
|
|
837
|
+
},
|
|
838
|
+
});
|
|
807
839
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
808
840
|
export function updateSafeSchema(version) {
|
|
809
841
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -3527,6 +3559,8 @@ function initializeStore(db, file) {
|
|
|
3527
3559
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
3528
3560
|
db.exec(MONITORING_SCHEMA);
|
|
3529
3561
|
db.exec(INTEGRATIONS_SCHEMA);
|
|
3562
|
+
// v123: the tool-secret sealing key's record and where secrets were kept before (tool-secret-store.ts): new tables.
|
|
3563
|
+
db.exec(TOOL_SECRET_KEY_SCHEMA);
|
|
3530
3564
|
db.exec(LEAD_ASK_SCHEMA);
|
|
3531
3565
|
// Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
|
|
3532
3566
|
db.exec(CHAT_DECIDE_SCHEMA);
|
|
@@ -3613,6 +3647,16 @@ function initializeStore(db, file) {
|
|
|
3613
3647
|
// v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
|
|
3614
3648
|
addColumn(db, "flow_trigger_event", "task", "TEXT");
|
|
3615
3649
|
db.exec(PLAN_AUTO_SCHEMA);
|
|
3650
|
+
// v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
|
|
3651
|
+
// armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
|
|
3652
|
+
db.exec(FLOW_UNATTENDED_SCHEMA);
|
|
3653
|
+
// v123: whether a trigger's firing was the clock's own (flow_trigger_event.on_time, V123_ADDED_COLUMNS: a schedule
|
|
3654
|
+
// due, not Run now; only those can run on their own), and what code an on-time script firing a grant signed for ran
|
|
3655
|
+
// (flow_trigger_event.ran_script, V123_ADDED_COLUMNS: flow-unattended.ts scriptRanDigestOf; older firings read null).
|
|
3656
|
+
for (const [table, columns] of Object.entries(V123_ADDED_COLUMNS)) {
|
|
3657
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
3658
|
+
addColumn(db, table, column, definition);
|
|
3659
|
+
}
|
|
3616
3660
|
// v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
|
|
3617
3661
|
for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
|
|
3618
3662
|
for (const [column, { definition }] of Object.entries(columns))
|
|
@@ -4818,6 +4862,36 @@ export class Store {
|
|
|
4818
4862
|
close() {
|
|
4819
4863
|
this.db.close();
|
|
4820
4864
|
}
|
|
4865
|
+
/** The digest of the sealing key Toolroll made for its tool secrets; null before it made one (or in an older database). */
|
|
4866
|
+
toolSecretKeyDigest() {
|
|
4867
|
+
if (!tableExists(this.db, "tool_secret_key"))
|
|
4868
|
+
return null;
|
|
4869
|
+
const row = this.db.prepare("SELECT digest FROM tool_secret_key WHERE id = 1").get();
|
|
4870
|
+
return row === undefined ? null : String(row["digest"]);
|
|
4871
|
+
}
|
|
4872
|
+
/**
|
|
4873
|
+
* The folder secrets were kept in before Toolroll's own, as decided at this database's first start of a command that
|
|
4874
|
+
* can start builds (`record`): `probe` runs until then, and its answer (null: none) is kept for every later start.
|
|
4875
|
+
* Other commands never write, so a preview stays read-only and a busy database doesn't stop them.
|
|
4876
|
+
*/
|
|
4877
|
+
toolSecretLegacyDir(probe, now, options) {
|
|
4878
|
+
const read = () => tableExists(this.db, "tool_secret_legacy") ? this.db.prepare("SELECT dir FROM tool_secret_legacy WHERE id = 1").get() : undefined;
|
|
4879
|
+
let row = read();
|
|
4880
|
+
if (row === undefined) {
|
|
4881
|
+
if (!options.record)
|
|
4882
|
+
return probe();
|
|
4883
|
+
this.db.prepare("INSERT OR IGNORE INTO tool_secret_legacy (id, dir, decided_at) VALUES (1, ?, ?)").run(probe(), now.toISOString());
|
|
4884
|
+
row = read();
|
|
4885
|
+
}
|
|
4886
|
+
return row?.["dir"] === null || row?.["dir"] === undefined ? null : String(row["dir"]);
|
|
4887
|
+
}
|
|
4888
|
+
/** Record a new sealing key's digest only if the one recorded is still `expected` (null: none yet). */
|
|
4889
|
+
swapToolSecretKeyDigest(expected, next, now) {
|
|
4890
|
+
const changed = expected === null
|
|
4891
|
+
? this.db.prepare("INSERT OR IGNORE INTO tool_secret_key (id, digest, made_at) VALUES (1, ?, ?)").run(next, now.toISOString()).changes
|
|
4892
|
+
: this.db.prepare("UPDATE tool_secret_key SET digest = ?, made_at = ? WHERE id = 1 AND digest = ?").run(next, now.toISOString(), expected).changes;
|
|
4893
|
+
return Number(changed) === 1;
|
|
4894
|
+
}
|
|
4821
4895
|
// ---- the built-in task store -------------------------------------------
|
|
4822
4896
|
/**
|
|
4823
4897
|
* Creating a task also creates its overlay reference, because a task with no
|
|
@@ -6344,7 +6418,9 @@ export class Store {
|
|
|
6344
6418
|
? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
6345
6419
|
: basis.kind === "chat"
|
|
6346
6420
|
? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
|
|
6347
|
-
:
|
|
6421
|
+
: basis.kind === "grant"
|
|
6422
|
+
? "Approved automatically: its flow runs on its own. A connected worker can take it next."
|
|
6423
|
+
: "Approved automatically under the operating mode. A connected worker can take it next.",
|
|
6348
6424
|
link: chatControlHref("task", taskId),
|
|
6349
6425
|
}, now);
|
|
6350
6426
|
}
|
|
@@ -6375,16 +6451,19 @@ export class Store {
|
|
|
6375
6451
|
* scopes answer true: this belt only ever REMOVES mode authority.
|
|
6376
6452
|
*/
|
|
6377
6453
|
modeApprovalLive(taskRef, now) {
|
|
6454
|
+
// A flow's "runs on its own" grant (flow-unattended.ts) is held to the same belt.
|
|
6378
6455
|
const dead = this.db
|
|
6379
6456
|
.prepare(`SELECT 1 AS dead FROM task_scope
|
|
6380
6457
|
JOIN task_ref ON task_ref.id = ? AND task_ref.backend = 'built-in' AND task_scope.task_id = task_ref.external_id
|
|
6381
|
-
WHERE task_scope.
|
|
6458
|
+
WHERE task_scope.approved_at IS NOT NULL
|
|
6459
|
+
AND ((task_scope.approval_basis = 'mode'
|
|
6382
6460
|
AND NOT EXISTS (SELECT 1 FROM operating_mode om
|
|
6383
6461
|
JOIN approver signer ON signer.name = om.signed_by
|
|
6384
6462
|
WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
|
|
6385
6463
|
AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
|
|
6386
|
-
AND signer.revoked_at IS NULL AND signer.role = 'approver')
|
|
6387
|
-
|
|
6464
|
+
AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", "task_ref.repo")}))
|
|
6465
|
+
OR (task_scope.approval_basis = 'grant' AND NOT ${grantStandsSql("task_scope.mode_digest", "?", "task_ref.repo")}))`)
|
|
6466
|
+
.get(taskRef, now.toISOString(), now.toISOString());
|
|
6388
6467
|
return dead === undefined;
|
|
6389
6468
|
}
|
|
6390
6469
|
// ---- approvers ----------------------------------------------------------
|
|
@@ -6511,6 +6590,29 @@ export class Store {
|
|
|
6511
6590
|
return true;
|
|
6512
6591
|
});
|
|
6513
6592
|
}
|
|
6593
|
+
/** A flow's "runs on its own" grant ended (revoked, expired, renewed): the undispatched work it sealed falls back
|
|
6594
|
+
* to a person, as a mode's does, and plans it armed lose their pre-authorization. Running and finished work keeps
|
|
6595
|
+
* its history. */
|
|
6596
|
+
withdrawGrantApprovals(digest, now) {
|
|
6597
|
+
const demotable = `WHERE approval_basis = 'grant' AND approved_at IS NOT NULL AND mode_digest = ?
|
|
6598
|
+
AND EXISTS (SELECT 1 FROM task_ref JOIN task ON task.id = task_ref.external_id
|
|
6599
|
+
WHERE task_ref.backend = 'built-in' AND task_ref.external_id = task_scope.task_id AND task.state = 'queued')
|
|
6600
|
+
AND NOT EXISTS (SELECT 1 FROM claim JOIN task_ref tr ON tr.id = claim.task_ref
|
|
6601
|
+
WHERE tr.backend = 'built-in' AND tr.external_id = task_scope.task_id
|
|
6602
|
+
AND claim.released_at IS NULL AND claim.expires_at > ?)`;
|
|
6603
|
+
const demoted = this.db.prepare(`SELECT task_id FROM task_scope ${demotable}`).all(digest, now.toISOString()).map(row => String(row["task_id"]));
|
|
6604
|
+
this.db
|
|
6605
|
+
.prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL,
|
|
6606
|
+
approved_profile_json = NULL, approval_basis = 'password', mode_digest = NULL ${demotable}`)
|
|
6607
|
+
.run(digest, now.toISOString());
|
|
6608
|
+
this.db.prepare("DELETE FROM plan_authorization WHERE basis = 'grant' AND mode_digest = ?").run(digest);
|
|
6609
|
+
for (const taskId of demoted) {
|
|
6610
|
+
const ref = this.lookupRef(taskId);
|
|
6611
|
+
if (ref === null)
|
|
6612
|
+
continue;
|
|
6613
|
+
this.noteLifecycle({ taskRef: ref.id, kind: "approval-withdrawn", identity: `t${ref.id}`, subject: "Approval needs renewing", body: "Its flow no longer runs on its own. Approve it before it runs.", link: chatControlHref("approval", taskId) }, now);
|
|
6614
|
+
}
|
|
6615
|
+
}
|
|
6514
6616
|
/** F2/R-REVOKE: the ONE reconciliation road. newMode null = no live
|
|
6515
6617
|
* automerge authority (revocation, expiry, or a notify posture):
|
|
6516
6618
|
* mode-basis pending/claimed intents drop to waiting-human, leases
|
|
@@ -6735,6 +6837,7 @@ export class Store {
|
|
|
6735
6837
|
for (const row of this.db.prepare("SELECT repo FROM operating_mode WHERE signed_by = ? AND revoked_at IS NULL").all(name)) {
|
|
6736
6838
|
this.revokeMode(String(row["repo"]), by, "operator", now);
|
|
6737
6839
|
}
|
|
6840
|
+
endUnattendedGrants(this, { signer: name }, by, "access changed", now);
|
|
6738
6841
|
this.revokeDerivedAuthority(name, by, now);
|
|
6739
6842
|
for (const repo of [...new Set([...(account.projects ?? []), ...(access ?? [])])]) {
|
|
6740
6843
|
this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null,
|
|
@@ -6762,6 +6865,10 @@ export class Store {
|
|
|
6762
6865
|
ON CONFLICT(repo) DO UPDATE SET not_requester = excluded.not_requester, protect_project = excluded.protect_project, protected_paths = excluded.protected_paths, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
|
|
6763
6866
|
.run(repo, next.notRequester ? 1 : 0, next.protectProject ? 1 : 0, JSON.stringify(next.protectedPaths), by, now.toISOString());
|
|
6764
6867
|
this.recordPolicy(by, repo, "approval rules changed", rulesWords(before), rulesWords(next), now);
|
|
6868
|
+
// Protected work is always a person's: a flow here (or building here) stops running on its own, and its queued
|
|
6869
|
+
// approvals are withdrawn (flow-unattended.ts). The dispatch belts check the rules again for any race.
|
|
6870
|
+
if (isProtectedWork(next, []))
|
|
6871
|
+
endUnattendedGrants(this, { repo }, by, "project protected", now);
|
|
6765
6872
|
});
|
|
6766
6873
|
}
|
|
6767
6874
|
/** Who filed a task, when anyone knows. */
|
|
@@ -12325,6 +12432,12 @@ export class Store {
|
|
|
12325
12432
|
.prepare("UPDATE run SET auth_mode = COALESCE(auth_mode, ?), terminal_class = ? WHERE id = ? AND terminal_class IS NULL")
|
|
12326
12433
|
.run(authMode, terminalClass, id);
|
|
12327
12434
|
}
|
|
12435
|
+
/** A launch refused before any model turn (an installed CLI too old for a
|
|
12436
|
+
* build's flags): the start stamp goes, its version and evidence stay. Only
|
|
12437
|
+
* while the run is open, so a settled run's account never changes. */
|
|
12438
|
+
withdrawProviderStart(id) {
|
|
12439
|
+
this.db.prepare("UPDATE run SET provider_started_at = NULL WHERE id = ? AND outcome IS NULL").run(id);
|
|
12440
|
+
}
|
|
12328
12441
|
/** The race-refusal stamp (Phase 3 C5): the version that was REFUSED,
|
|
12329
12442
|
* recorded without provider_started_at — no provider process started. */
|
|
12330
12443
|
stampProviderVersion(id, providerVersion) {
|
|
@@ -14928,6 +15041,8 @@ export class Store {
|
|
|
14928
15041
|
const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
|
|
14929
15042
|
if (current !== null)
|
|
14930
15043
|
this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
|
|
15044
|
+
// A "runs on its own" grant signed this script's code: a new version ends it (flow-unattended.ts).
|
|
15045
|
+
endUnattendedGrants(this, { script: { repo: script.repo, name: script.name } }, script.by, "script changed", now);
|
|
14931
15046
|
this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
|
|
14932
15047
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
|
|
14933
15048
|
return version;
|
|
@@ -14938,9 +15053,14 @@ export class Store {
|
|
|
14938
15053
|
const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
|
|
14939
15054
|
return Number(changes) === 1;
|
|
14940
15055
|
}
|
|
14941
|
-
removeFlowScript(repo, name) {
|
|
14942
|
-
|
|
14943
|
-
|
|
15056
|
+
removeFlowScript(repo, name, by = "flow", now = new Date()) {
|
|
15057
|
+
return this.transact(() => {
|
|
15058
|
+
const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
|
|
15059
|
+
// A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
|
|
15060
|
+
if (Number(changes) === 1)
|
|
15061
|
+
endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
|
|
15062
|
+
return Number(changes) === 1;
|
|
15063
|
+
});
|
|
14944
15064
|
}
|
|
14945
15065
|
/** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
|
|
14946
15066
|
claimFlowStep(step, now) {
|
|
@@ -15029,6 +15149,13 @@ export class Store {
|
|
|
15029
15149
|
// ---- flow triggers (v82) -----------------------------------------------------
|
|
15030
15150
|
addFlowTrigger(trigger, now) {
|
|
15031
15151
|
const stamp = now.toISOString();
|
|
15152
|
+
// A new schedule is one the flow's "runs on its own" grant never read: it ends (flow-unattended.ts).
|
|
15153
|
+
if (trigger.kind === "schedule")
|
|
15154
|
+
return this.transact(() => {
|
|
15155
|
+
endUnattendedGrants(this, { flow: trigger.flow }, trigger.by, "schedule changed", now);
|
|
15156
|
+
return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
|
|
15157
|
+
.run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
|
|
15158
|
+
});
|
|
15032
15159
|
return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
|
|
15033
15160
|
.run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
|
|
15034
15161
|
}
|
|
@@ -15049,11 +15176,17 @@ export class Store {
|
|
|
15049
15176
|
const row = this.db.prepare("SELECT t.* FROM flow_trigger t JOIN flow f ON f.id = t.flow WHERE t.hook_hash = ? AND t.state = 'active' AND f.state = 'active'").get(hash);
|
|
15050
15177
|
return row === undefined ? null : readFlowTriggerRow(row);
|
|
15051
15178
|
}
|
|
15052
|
-
updateFlowTrigger(id, change, now) {
|
|
15179
|
+
updateFlowTrigger(id, change, now, by = "a person") {
|
|
15053
15180
|
const trigger = this.getFlowTrigger(id);
|
|
15054
15181
|
if (trigger === null)
|
|
15055
15182
|
return;
|
|
15056
|
-
|
|
15183
|
+
// Pausing, resuming or removing a schedule ends the flow's "runs on its own" grant (flow-unattended.ts).
|
|
15184
|
+
const write = () => this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
|
|
15185
|
+
if (trigger.kind !== "schedule" || change.state === undefined || change.state === trigger.state) {
|
|
15186
|
+
write();
|
|
15187
|
+
return;
|
|
15188
|
+
}
|
|
15189
|
+
this.transact(() => { endUnattendedGrants(this, { flow: trigger.flow }, by, "schedule changed", now); write(); });
|
|
15057
15190
|
}
|
|
15058
15191
|
/** Whether a trigger already handled this outside thing (an issue, a run, a schedule slot). */
|
|
15059
15192
|
flowTriggerSaw(trigger, key) {
|
|
@@ -15074,8 +15207,8 @@ export class Store {
|
|
|
15074
15207
|
* Record what a trigger did with one outside thing: the card it made, or why it made none, and (a standing order's
|
|
15075
15208
|
* firing) the task it filed. False when it was already recorded.
|
|
15076
15209
|
*/
|
|
15077
|
-
recordFlowTriggerEvent(trigger, key, card, note, now, task = null) {
|
|
15078
|
-
const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task) VALUES (?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task);
|
|
15210
|
+
recordFlowTriggerEvent(trigger, key, card, note, now, task = null, onTime = false, ranScript = null) {
|
|
15211
|
+
const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task, on_time, ran_script) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task, onTime ? 1 : 0, ranScript);
|
|
15079
15212
|
return Number(changes) === 1;
|
|
15080
15213
|
}
|
|
15081
15214
|
/** The last card a trigger made, if any. */
|
package/dist/subagent-tools.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type ToolLister = (spec: ToolSpec, values: Record<string, string>) => Pro
|
|
|
12
12
|
export type ToolIo = {
|
|
13
13
|
callTool?: ToolCaller;
|
|
14
14
|
listTools?: ToolLister;
|
|
15
|
-
toolHome?: string;
|
|
15
|
+
toolHome?: string | undefined;
|
|
16
16
|
};
|
|
17
17
|
/** Actions whose names say they only read start as "do it"; everything else asks first until its manager says otherwise. */
|
|
18
18
|
export declare const READ_VERB: RegExp;
|
package/dist/surface.js
CHANGED
|
@@ -116,8 +116,9 @@ const GUIDE_ROWS = [
|
|
|
116
116
|
...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
|
|
117
117
|
...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
|
|
118
118
|
// Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// Letting a flow run on its own raises authority: the owner's own password ceremony, never an agent's act.
|
|
120
|
+
...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags,
|
|
121
|
+
...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : name === "card" ? "a card id from flows show" : name === "on|off" ? "on signs the grant, off revokes it" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
|
|
121
122
|
...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
|
|
122
123
|
// ---- the queue (agent surface) ----
|
|
123
124
|
{ invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
@@ -411,6 +412,7 @@ const GUIDE_ROWS = [
|
|
|
411
412
|
operator("up", "console + worker + browser, one command"),
|
|
412
413
|
operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
|
|
413
414
|
operator("daemon", "install the loop under the OS service manager"),
|
|
415
|
+
{ ...operatorRead("doctor", "check that Toolroll's service holds no key, token or password a build could read; names what it finds and the steps to fix it, never a value, and changes nothing"), flags: [jsonFlag] },
|
|
414
416
|
];
|
|
415
417
|
/**
|
|
416
418
|
* Whether each row may run on a central server for a person signed in with their own API token (`runOperateAs` in
|
|
@@ -429,7 +431,7 @@ const REMOTE_NO = [
|
|
|
429
431
|
// Workers, the loop and the services that run here.
|
|
430
432
|
"claim", "heartbeat", "worker release", "reap", "tick", "build", "reconcile",
|
|
431
433
|
"runner register", "runner retire", "runner bind", "runner capacity", "coordinator mint", "coordinator revoke", "mcp", "outbox deliver", "peek",
|
|
432
|
-
"serve", "serve check-public", "watch", "up", "onboard", "daemon", "bridge", "setup show", "setup clear",
|
|
434
|
+
"serve", "serve check-public", "watch", "up", "onboard", "daemon", "doctor", "bridge", "setup show", "setup clear",
|
|
433
435
|
// Provider keys and model tools on this machine.
|
|
434
436
|
"providers", "keys status", "keys set", "keys clear", "keys verify", "keys auth", "models check", "models update", "models watch",
|
|
435
437
|
// Publication and release.
|
|
@@ -443,7 +445,7 @@ const REMOTE_NO = [
|
|
|
443
445
|
];
|
|
444
446
|
const REMOTE_STEP_UP = [
|
|
445
447
|
"proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle", "knowledge apply", "memory apply",
|
|
446
|
-
"flows card approve", "flows card send-back", "flows script approve",
|
|
448
|
+
"flows card approve", "flows card send-back", "flows script approve", "flows unattended",
|
|
447
449
|
"approver list", "approver add", "people list", "people invite", "people projects", "people revoke",
|
|
448
450
|
// A person's own API tokens: a password step-up on the server's machine, or the console. Never with a token.
|
|
449
451
|
"tokens create", "tokens list", "tokens revoke", "tokens rotate",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ExecResult } from "./exec.js";
|
|
2
|
+
import { type Runner } from "./worktree.js";
|
|
3
|
+
export type Git = (cwd: string, args: readonly string[]) => Promise<ExecResult>;
|
|
4
|
+
/** Fixed on every teardown command, ahead of its own arguments. */
|
|
5
|
+
export declare const TEARDOWN_FLAGS: readonly string[];
|
|
6
|
+
/** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
|
|
7
|
+
export declare const REFUSED_CODE = 128;
|
|
8
|
+
/** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
|
|
9
|
+
* scope count: teardown commands never read system or global config. */
|
|
10
|
+
export declare function commandSetting(listing: string): string | null;
|
|
11
|
+
/** Why a teardown command was refused, or null when `result` isn't a refusal. */
|
|
12
|
+
export declare function refusal(result: ExecResult): string | null;
|
|
13
|
+
/** The one way teardown runs git, including the target checkout immediately before removal. */
|
|
14
|
+
export declare function teardownGit(runner?: Runner): Git;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git for teardown (landed-cleanup.ts and the checkout removal it drives in worktree.ts), run by Toolroll's own
|
|
3
|
+
* process inside the shared repository a build can write to. A build can plant hooks in .git/hooks and settings in
|
|
4
|
+
* .git/config (or a worktree's config.worktree), so every teardown command runs hardened: an allowlisted environment
|
|
5
|
+
* with no system or global config (remote-main.ts's), no hooks, no fsmonitor, no ext transport, no prompts and no
|
|
6
|
+
* automatic maintenance.
|
|
7
|
+
*
|
|
8
|
+
* Settings that name a command, and includes (which could bring them in from a file nobody looked at), are not
|
|
9
|
+
* overridden but refused: before each command the repository's own config is read with includes off, and if it sets
|
|
10
|
+
* any of them the command never runs. The refusal is a failed git result, so every caller's existing doubt applies and
|
|
11
|
+
* nothing is removed.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { run } from "./exec.js";
|
|
16
|
+
import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
|
|
17
|
+
import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
|
|
18
|
+
/** Fixed on every teardown command, ahead of its own arguments. */
|
|
19
|
+
export const TEARDOWN_FLAGS = [
|
|
20
|
+
"--no-optional-locks", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never",
|
|
21
|
+
"-c", "credential.helper=", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "submodule.recurse=false",
|
|
22
|
+
];
|
|
23
|
+
/** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
|
|
24
|
+
export const REFUSED_CODE = 128;
|
|
25
|
+
const REFUSED_PREFIX = "Toolroll won't run git here: ";
|
|
26
|
+
/** Config keys that run a command, bring in unread config, or redirect inspection away from the checked path.
|
|
27
|
+
* `*` is one subsection, any name. */
|
|
28
|
+
const COMMAND_KEYS = [
|
|
29
|
+
"core.sshcommand", "core.gitproxy", "core.askpass", "core.pager", "core.editor", "core.alternaterefscommand",
|
|
30
|
+
"core.worktree", "gpg.ssh.defaultkeycommand", "trailer.*.command", "trailer.*.cmd", "submodule.*.update", "sequence.editor", "diff.external", "credential.helper", "credential.*.helper", "filter.*.clean", "filter.*.smudge", "filter.*.process",
|
|
31
|
+
"diff.*.command", "diff.*.textconv", "merge.*.driver", "gpg.program", "gpg.*.program", "uploadpack.packobjectshook",
|
|
32
|
+
"remote.*.uploadpack", "remote.*.receivepack", "remote.*.vcs", "pager.*", "include.path", "includeif.*.path",
|
|
33
|
+
].map(key => new RegExp(`^${key.replace(/\./g, "\\.").replace(/\*/g, ".+")}$`));
|
|
34
|
+
/** core.fsmonitor as a boolean is git's own daemon; anything else is a command to run. */
|
|
35
|
+
const BOOLEAN = /^(|true|false|yes|no|on|off|-?\d+)$/i;
|
|
36
|
+
/** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
|
|
37
|
+
* scope count: teardown commands never read system or global config. */
|
|
38
|
+
export function commandSetting(listing) {
|
|
39
|
+
const fields = listing.split("\0");
|
|
40
|
+
for (let at = 0; at + 1 < fields.length; at += 2) {
|
|
41
|
+
const scope = fields[at];
|
|
42
|
+
const entry = fields[at + 1];
|
|
43
|
+
if (scope !== "local" && scope !== "worktree")
|
|
44
|
+
continue;
|
|
45
|
+
const newline = entry.indexOf("\n");
|
|
46
|
+
const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
|
|
47
|
+
const value = newline < 0 ? "" : entry.slice(newline + 1);
|
|
48
|
+
if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)))
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** Why a teardown command was refused, or null when `result` isn't a refusal. */
|
|
54
|
+
export function refusal(result) {
|
|
55
|
+
return result.code === REFUSED_CODE && result.stderr.startsWith(REFUSED_PREFIX) ? result.stderr.slice(REFUSED_PREFIX.length).trim() : null;
|
|
56
|
+
}
|
|
57
|
+
const refused = (why) => ({ code: REFUSED_CODE, stdout: "", stderr: `${REFUSED_PREFIX}${why}\n`, timedOut: false, notFound: false });
|
|
58
|
+
/** Refuse nested repositories without asking Git to enter them or evaluate their config.
|
|
59
|
+
* Includes ignored/untracked repositories and bare repositories; never follows symlinks. */
|
|
60
|
+
function nestedRepository(path) {
|
|
61
|
+
const pending = [path];
|
|
62
|
+
while (pending.length > 0) {
|
|
63
|
+
const dir = pending.pop();
|
|
64
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
65
|
+
if (dir !== path && (entries.some(entry => entry.name === ".git") ||
|
|
66
|
+
["HEAD", "objects", "config"].every(name => entries.some(entry => entry.name === name))))
|
|
67
|
+
return true;
|
|
68
|
+
for (const entry of entries)
|
|
69
|
+
if (entry.isDirectory() && entry.name !== ".git")
|
|
70
|
+
pending.push(join(dir, entry.name));
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** The one way teardown runs git, including the target checkout immediately before removal. */
|
|
75
|
+
export function teardownGit(runner = run) {
|
|
76
|
+
const options = (cwd) => ({ cwd, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
|
|
77
|
+
const configProblem = async (cwd) => {
|
|
78
|
+
const config = await runner("git", ["--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], options(cwd));
|
|
79
|
+
if (config.code !== 0)
|
|
80
|
+
return refused("its config couldn't be read");
|
|
81
|
+
const key = commandSetting(config.stdout);
|
|
82
|
+
return key === null ? null : refused(`the project's git config sets ${key}, which can run a command`);
|
|
83
|
+
};
|
|
84
|
+
return async (cwd, args) => {
|
|
85
|
+
const problem = await configProblem(cwd);
|
|
86
|
+
if (problem !== null)
|
|
87
|
+
return problem;
|
|
88
|
+
const removal = args[0] === "worktree" && args[1] === "remove";
|
|
89
|
+
const inspection = args.includes("status") || args.includes("diff");
|
|
90
|
+
const target = removal ? resolve(cwd, args.at(-1)) : cwd;
|
|
91
|
+
if (removal) {
|
|
92
|
+
const targetProblem = await configProblem(target);
|
|
93
|
+
if (targetProblem !== null)
|
|
94
|
+
return targetProblem;
|
|
95
|
+
}
|
|
96
|
+
if (removal || inspection || args.includes("checkout") || args.includes("clean")) {
|
|
97
|
+
try {
|
|
98
|
+
if (existsSync(target) && nestedRepository(target))
|
|
99
|
+
return refused("the checkout contains a nested repository");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return refused("the checkout couldn't be inspected for nested repositories");
|
|
103
|
+
}
|
|
104
|
+
// ls-files reads index entries only; it cannot run submodule filters. Even an uninitialized
|
|
105
|
+
// gitlink is kept, so worktree remove's internal status can never traverse it.
|
|
106
|
+
const index = await runner("git", [...TEARDOWN_FLAGS, "ls-files", "--stage", "-z"], options(target));
|
|
107
|
+
if (index.code !== 0)
|
|
108
|
+
return refused("the checkout's index couldn't be read");
|
|
109
|
+
if (index.stdout.split("\0").some(entry => entry.startsWith("160000 ")))
|
|
110
|
+
return refused("the checkout contains a gitlink");
|
|
111
|
+
}
|
|
112
|
+
const safeArgs = [...args];
|
|
113
|
+
if (inspection && !safeArgs.includes("--ignore-submodules=all")) {
|
|
114
|
+
const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
|
|
115
|
+
safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
|
|
116
|
+
}
|
|
117
|
+
return runner("git", [...TEARDOWN_FLAGS, ...safeArgs], options(cwd));
|
|
118
|
+
};
|
|
119
|
+
}
|