toolroll 0.9.63 → 0.9.67
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 +108 -0
- package/README.md +33 -5
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +117 -2
- package/dist/agent-fence.js +439 -12
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +39 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +228 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +235 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +10 -1
- package/dist/builder.js +87 -24
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +197 -15
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +19 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +21 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/console-api.d.ts +13 -4
- package/dist/contracts/console-api.js +13 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +4 -1
- package/dist/doctor.js +1 -1
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/exec.d.ts +78 -0
- package/dist/exec.js +367 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-engine.js +24 -5
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +77 -8
- package/dist/flow-triggers.js +517 -70
- package/dist/flows-cli.js +8 -2
- package/dist/flows-ui.d.ts +0 -2
- package/dist/flows-ui.js +52 -4
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- package/dist/invoke.d.ts +35 -0
- package/dist/invoke.js +347 -28
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +152 -1
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +269 -0
- package/dist/live-terminal.js +686 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +335 -19
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- package/dist/project-delete-ui.d.ts +18 -0
- package/dist/project-delete-ui.js +60 -9
- package/dist/project-delete.js +2 -2
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +5 -3
- package/dist/project-tools.js +12 -9
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider.d.ts +25 -2
- package/dist/provider.js +63 -9
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +10 -1
- package/dist/release.js +34 -15
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-follow-ups.js +3 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +183 -5
- package/dist/scope.js +256 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +248 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +16 -2
- package/dist/server/chrome.js +63 -11
- package/dist/server/console.css +107 -42
- package/dist/server/flows.js +38 -8
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +32 -4
- package/dist/server/render-pages.js +83 -13
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +111 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +47 -5
- package/dist/server/runtime.d.ts +33 -2
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +142 -10
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +327 -78
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- package/dist/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +440 -15
- package/dist/store.js +1072 -52
- package/dist/subagent-desk.js +1 -1
- package/dist/summary.js +3 -0
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/teardown-git.d.ts +22 -0
- package/dist/teardown-git.js +136 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +14 -0
- package/dist/tool-broker.js +67 -2
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +16 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- package/package.json +15 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook floods (docs/plans/webhook-tasks.md §4): how many deliveries a trigger's address may fail in a minute. Held
|
|
3
|
+
* in memory per store (a restart forgets it, which is harmless: it protects the process and the database's write rate,
|
|
4
|
+
* not anything durable). It counts only what doesn't prove itself — a bad, missing or stale signature, an address that
|
|
5
|
+
* isn't set up, an unknown address, and every delivery to an unsigned webhook (nothing proves those) — so a stranger
|
|
6
|
+
* holding a leaked address can't crowd out the deliveries that are signed.
|
|
7
|
+
*/
|
|
8
|
+
import type { Store } from "./store.js";
|
|
9
|
+
/** At most this many unproven deliveries per address a minute; over it, further ones are answered 429 and do nothing. */
|
|
10
|
+
export declare const UNPROVEN_PER_MINUTE = 60;
|
|
11
|
+
/** Addresses that match no trigger share one count. */
|
|
12
|
+
export declare const UNKNOWN_ADDRESS = "unknown";
|
|
13
|
+
/** Count one unproven delivery to `address` at `now`. Whether it is over the minute's limit, and when to try again. */
|
|
14
|
+
export declare function countUnproven(store: Store, address: string, now: Date): {
|
|
15
|
+
over: false;
|
|
16
|
+
} | {
|
|
17
|
+
over: true;
|
|
18
|
+
retryAfterSeconds: number;
|
|
19
|
+
};
|
|
20
|
+
/** At most this many cards a trigger makes in a rolling hour (counted when each card is made). */
|
|
21
|
+
export declare const CARDS_PER_HOUR = 30;
|
|
22
|
+
/** At most this many signed deliveries a trigger holds while it waits for room; beyond it, deliveries are turned away. */
|
|
23
|
+
export declare const HELD_AT_MOST = 200;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** At most this many unproven deliveries per address a minute; over it, further ones are answered 429 and do nothing. */
|
|
2
|
+
export const UNPROVEN_PER_MINUTE = 60;
|
|
3
|
+
const WINDOW_MS = 60_000;
|
|
4
|
+
/** Addresses that match no trigger share one count. */
|
|
5
|
+
export const UNKNOWN_ADDRESS = "unknown";
|
|
6
|
+
const counters = new WeakMap();
|
|
7
|
+
/** Count one unproven delivery to `address` at `now`. Whether it is over the minute's limit, and when to try again. */
|
|
8
|
+
export function countUnproven(store, address, now) {
|
|
9
|
+
let counter = counters.get(store);
|
|
10
|
+
if (counter === undefined) {
|
|
11
|
+
counter = new Map();
|
|
12
|
+
counters.set(store, counter);
|
|
13
|
+
}
|
|
14
|
+
const at = now.getTime();
|
|
15
|
+
const recent = (counter.get(address) ?? []).filter(one => one > at - WINDOW_MS);
|
|
16
|
+
recent.push(at);
|
|
17
|
+
// Keep at most one over the limit: the count only needs to know it is over, and memory stays bounded per address.
|
|
18
|
+
counter.set(address, recent.slice(-(UNPROVEN_PER_MINUTE + 1)));
|
|
19
|
+
// Bounded across addresses too: the oldest addresses are forgotten first.
|
|
20
|
+
if (counter.size > 10_000)
|
|
21
|
+
counter.delete(counter.keys().next().value);
|
|
22
|
+
if (recent.length <= UNPROVEN_PER_MINUTE)
|
|
23
|
+
return { over: false };
|
|
24
|
+
const oldest = recent[recent.length - UNPROVEN_PER_MINUTE - 1];
|
|
25
|
+
return { over: true, retryAfterSeconds: Math.max(1, Math.ceil((oldest + WINDOW_MS - at) / 1000)) };
|
|
26
|
+
}
|
|
27
|
+
/** At most this many cards a trigger makes in a rolling hour (counted when each card is made). */
|
|
28
|
+
export const CARDS_PER_HOUR = 30;
|
|
29
|
+
/** At most this many signed deliveries a trigger holds while it waits for room; beyond it, deliveries are turned away. */
|
|
30
|
+
export const HELD_AT_MOST = 200;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { WebhookSender } from "./webhook-signing.js";
|
|
2
|
+
/** A webhook trigger's reading settings (its saved config, defaults filled). */
|
|
3
|
+
export type ReaderSettings = {
|
|
4
|
+
sender: WebhookSender;
|
|
5
|
+
title: string;
|
|
6
|
+
titleField: string | null;
|
|
7
|
+
bodyField: string | null;
|
|
8
|
+
idField: string | null;
|
|
9
|
+
urlField: string | null;
|
|
10
|
+
project: string | null;
|
|
11
|
+
team: string | null;
|
|
12
|
+
};
|
|
13
|
+
/** What one delivery is, once read: an item (with the outside thing's identity and link, when it has them), a delivery
|
|
14
|
+
* whose configured identity couldn't be used (made into nothing, with why), or one that isn't for this flow. */
|
|
15
|
+
export type ReadDelivery = {
|
|
16
|
+
kind: "item";
|
|
17
|
+
identity: string | null;
|
|
18
|
+
url: string | null;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
label: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "skip";
|
|
24
|
+
why: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "ignored";
|
|
27
|
+
why: string;
|
|
28
|
+
};
|
|
29
|
+
/** Whether outside words could hold a key or password (the same check cards' words get). */
|
|
30
|
+
export declare const looksSecret: (value: string) => boolean;
|
|
31
|
+
/** Where a sender's links may point: its own host, and (for Sentry) the organisation subdomains under it. */
|
|
32
|
+
export type LinkHosts = readonly {
|
|
33
|
+
host: string;
|
|
34
|
+
subdomains: boolean;
|
|
35
|
+
}[];
|
|
36
|
+
export declare const SENTRY_HOSTS: LinkHosts;
|
|
37
|
+
export declare const VERCEL_HOSTS: LinkHosts;
|
|
38
|
+
/**
|
|
39
|
+
* A link kept from a delivery: absolute https, at most 2,000 characters, no user information, and with its query and
|
|
40
|
+
* fragment dropped (where tokens travel). `hosts` narrows it to a sender's own site (and its subdomains). Null when it
|
|
41
|
+
* isn't one, or when what is left could still hold a secret.
|
|
42
|
+
*/
|
|
43
|
+
export declare function safeLink(value: unknown, hosts: LinkHosts | null): string | null;
|
|
44
|
+
/** The identity words a key may carry: at most 120 characters, nothing hidden, and nothing that looks like a secret. */
|
|
45
|
+
export declare function identityOf(value: unknown): {
|
|
46
|
+
ok: true;
|
|
47
|
+
id: string;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
why: string;
|
|
51
|
+
};
|
|
52
|
+
/** Whether each provider's reader has read a real, captured delivery (test/fixtures/webhooks/*.captured.json), or only
|
|
53
|
+
* its sender's documented examples so far: the panel says which until a captured one is in the suite. */
|
|
54
|
+
export declare const READER_CAPTURED: Record<"sentry" | "vercel", boolean>;
|
|
55
|
+
/** What a proven delivery says, by its sender's reader. */
|
|
56
|
+
export declare function readDelivery(body: unknown, settings: ReaderSettings): ReadDelivery;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a proven webhook delivery says, read by its sender's reader (docs/plans/webhook-tasks.md §2). A signature proves
|
|
3
|
+
* who sent a delivery, not that it belongs in this flow: Sentry's and Vercel's readers check its shape, its action and
|
|
4
|
+
* the configured scope first, and only then give it an identity. Anything else is ignored, with why, and claims no
|
|
5
|
+
* identity. The generic senders pick their fields by dot-paths. Every outside value is screened for secrets before it
|
|
6
|
+
* is used anywhere (a key, a held item, a card, a task), and every link is made safe before it is kept.
|
|
7
|
+
*/
|
|
8
|
+
import { scanForSecrets } from "./evidence.js";
|
|
9
|
+
const HIDDEN = /[\u0000-\u001f\u007f---]/;
|
|
10
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
11
|
+
const pick = (body, path) => path.split(".").reduce((node, part) => record(node)[part], body);
|
|
12
|
+
const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
13
|
+
/** A string or a safe integer, as an identity's words exactly as sent; anything else isn't one. */
|
|
14
|
+
const idWords = (value) => typeof value === "string" ? value : typeof value === "number" && Number.isSafeInteger(value) ? String(value) : null;
|
|
15
|
+
/** Whether outside words could hold a key or password (the same check cards' words get). */
|
|
16
|
+
export const looksSecret = (value) => scanForSecrets(value).length > 0;
|
|
17
|
+
export const SENTRY_HOSTS = [{ host: "sentry.io", subdomains: true }];
|
|
18
|
+
export const VERCEL_HOSTS = [{ host: "vercel.com", subdomains: false }];
|
|
19
|
+
/**
|
|
20
|
+
* A link kept from a delivery: absolute https, at most 2,000 characters, no user information, and with its query and
|
|
21
|
+
* fragment dropped (where tokens travel). `hosts` narrows it to a sender's own site (and its subdomains). Null when it
|
|
22
|
+
* isn't one, or when what is left could still hold a secret.
|
|
23
|
+
*/
|
|
24
|
+
export function safeLink(value, hosts) {
|
|
25
|
+
if (typeof value !== "string" || value.length > 2000 || HIDDEN.test(value))
|
|
26
|
+
return null;
|
|
27
|
+
let url;
|
|
28
|
+
try {
|
|
29
|
+
url = new URL(value);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "")
|
|
35
|
+
return null;
|
|
36
|
+
const host = url.hostname.toLowerCase();
|
|
37
|
+
if (hosts !== null && !hosts.some(one => host === one.host || (one.subdomains && host.endsWith(`.${one.host}`))))
|
|
38
|
+
return null;
|
|
39
|
+
const kept = `${url.origin}${url.pathname}`;
|
|
40
|
+
// Judged as it will be kept: an address that only normalises past 2,000 characters (escaped non-ASCII) isn't kept.
|
|
41
|
+
if (kept.length > 2000)
|
|
42
|
+
return null;
|
|
43
|
+
// Screened as kept and as read: a token spelled with percent-escapes is the same token. A path that won't decode isn't kept.
|
|
44
|
+
let decoded;
|
|
45
|
+
try {
|
|
46
|
+
decoded = decodeURIComponent(url.pathname);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return looksSecret(kept) || looksSecret(decoded) || HIDDEN.test(decoded) ? null : kept;
|
|
52
|
+
}
|
|
53
|
+
/** The identity words a key may carry: at most 120 characters, nothing hidden, and nothing that looks like a secret. */
|
|
54
|
+
export function identityOf(value) {
|
|
55
|
+
const sent = idWords(value);
|
|
56
|
+
if (sent === null || sent.trim() === "")
|
|
57
|
+
return { ok: false, why: "its identity is missing" };
|
|
58
|
+
// Judged as sent, before anything is trimmed: padding, hidden characters or length make it unusable, never another id.
|
|
59
|
+
if (sent.length > 120 || HIDDEN.test(sent) || sent !== sent.trim())
|
|
60
|
+
return { ok: false, why: "its identity isn't usable" };
|
|
61
|
+
if (looksSecret(sent))
|
|
62
|
+
return { ok: false, why: "it looked like it held a key or password" };
|
|
63
|
+
return { ok: true, id: sent };
|
|
64
|
+
}
|
|
65
|
+
/** Sentry's API address for an event names its project's slug (…/api/0/projects/<org>/<project>/events/<id>/): read
|
|
66
|
+
* only from that exact shape on Sentry's own host. */
|
|
67
|
+
function sentrySlugOf(value) {
|
|
68
|
+
const link = typeof value === "string" ? value : "";
|
|
69
|
+
const match = /^https:\/\/(?:[a-z0-9-]+\.)?sentry\.io\/api\/0\/projects\/[a-z0-9_-]+\/([a-z0-9_-]+)\/events\/[A-Za-z0-9-]+\/?$/.exec(link);
|
|
70
|
+
return match?.[1] ?? null;
|
|
71
|
+
}
|
|
72
|
+
/** Sentry: an issue alert (action "triggered", with the event) or a new issue (action "created", with the issue). */
|
|
73
|
+
function readSentry(body, settings) {
|
|
74
|
+
const action = text(record(body)["action"]);
|
|
75
|
+
const data = record(record(body)["data"]);
|
|
76
|
+
const alert = action === "triggered" && Object.keys(record(data["event"])).length > 0;
|
|
77
|
+
const created = action === "created" && Object.keys(record(data["issue"])).length > 0;
|
|
78
|
+
// A fixed reason: nothing the sender wrote is kept for an event Toolroll doesn't take.
|
|
79
|
+
if (!alert && !created)
|
|
80
|
+
return { kind: "ignored", why: "not an issue alert or a new issue" };
|
|
81
|
+
const thing = record(alert ? data["event"] : data["issue"]);
|
|
82
|
+
// Scope from the body alone (the resource header isn't signed): the project's id or slug, as Sentry sends it.
|
|
83
|
+
if (settings.project !== null) {
|
|
84
|
+
const project = record(thing["project"]);
|
|
85
|
+
// An alert's event names its project by number, and by slug only in its API address; a new issue names both.
|
|
86
|
+
const names = [idWords(thing["project"]), idWords(project["id"]), text(project["slug"]), sentrySlugOf(thing["url"])].filter((one) => one !== null);
|
|
87
|
+
if (!names.some(one => one.toLowerCase() === settings.project.toLowerCase()))
|
|
88
|
+
return { kind: "ignored", why: "from another Sentry project" };
|
|
89
|
+
}
|
|
90
|
+
const identity = identityOf(alert ? thing["issue_id"] : thing["id"]);
|
|
91
|
+
if (!identity.ok)
|
|
92
|
+
return { kind: "skip", why: identity.why };
|
|
93
|
+
const title = text(thing["title"]) ?? settings.title;
|
|
94
|
+
const culprit = text(thing["culprit"]);
|
|
95
|
+
return { kind: "item", identity: identity.id, url: safeLink(thing["web_url"], SENTRY_HOSTS), title, description: culprit === null ? "From a Sentry issue." : `From a Sentry issue, in ${culprit}.`, label: "Sentry issue" };
|
|
96
|
+
}
|
|
97
|
+
/** Vercel: a failed deployment only, from the configured project and team. */
|
|
98
|
+
function readVercel(body, settings) {
|
|
99
|
+
const type = text(record(body)["type"]);
|
|
100
|
+
if (type !== "deployment.error")
|
|
101
|
+
return { kind: "ignored", why: "not a failed deployment" };
|
|
102
|
+
const payload = record(record(body)["payload"]);
|
|
103
|
+
if (settings.project !== null && idWords(record(payload["project"])["id"]) !== settings.project)
|
|
104
|
+
return { kind: "ignored", why: "from another Vercel project" };
|
|
105
|
+
if (settings.team !== null && idWords(record(payload["team"])["id"]) !== settings.team)
|
|
106
|
+
return { kind: "ignored", why: "from another Vercel team" };
|
|
107
|
+
const deployment = record(payload["deployment"]);
|
|
108
|
+
const identity = identityOf(deployment["id"]);
|
|
109
|
+
if (!identity.ok)
|
|
110
|
+
return { kind: "skip", why: identity.why };
|
|
111
|
+
const name = text(deployment["name"]);
|
|
112
|
+
return { kind: "item", identity: identity.id, url: safeLink(record(payload["links"])["deployment"], VERCEL_HOSTS), title: name === null ? settings.title : `${name} failed to deploy`,
|
|
113
|
+
description: "From a failed Vercel deployment.", label: "Vercel deployment" };
|
|
114
|
+
}
|
|
115
|
+
/** Any JSON: the trigger's own paths, then the usual names; shown whole when they find no text. */
|
|
116
|
+
function readGeneric(body, settings) {
|
|
117
|
+
const at = (path) => path === null ? undefined : pick(body, path);
|
|
118
|
+
let identity = null;
|
|
119
|
+
if (settings.idField !== null) {
|
|
120
|
+
const read = identityOf(at(settings.idField));
|
|
121
|
+
// A configured identity that can't be used makes nothing: it never falls back to some other identity.
|
|
122
|
+
if (!read.ok)
|
|
123
|
+
return { kind: "skip", why: read.why };
|
|
124
|
+
identity = read.id;
|
|
125
|
+
}
|
|
126
|
+
const title = at(settings.titleField ?? "title") ?? at("summary") ?? at("message");
|
|
127
|
+
const details = at(settings.bodyField ?? "description") ?? at("body") ?? at("text");
|
|
128
|
+
return { kind: "item", identity, url: settings.urlField === null ? null : safeLink(at(settings.urlField), null),
|
|
129
|
+
title: typeof title === "string" && title.trim() !== "" ? title : settings.title,
|
|
130
|
+
description: `From a webhook:\n\n${typeof details === "string" ? details : JSON.stringify(body, null, 2)}`.slice(0, 4000), label: "Webhook" };
|
|
131
|
+
}
|
|
132
|
+
/** Whether each provider's reader has read a real, captured delivery (test/fixtures/webhooks/*.captured.json), or only
|
|
133
|
+
* its sender's documented examples so far: the panel says which until a captured one is in the suite. */
|
|
134
|
+
export const READER_CAPTURED = { sentry: false, vercel: false };
|
|
135
|
+
/** What a proven delivery says, by its sender's reader. */
|
|
136
|
+
export function readDelivery(body, settings) {
|
|
137
|
+
return settings.sender === "sentry" ? readSentry(body, settings) : settings.sender === "vercel" ? readVercel(body, settings) : readGeneric(body, settings);
|
|
138
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { WEBHOOK_SENDERS } from "./contracts/flow.js";
|
|
2
|
+
export { WEBHOOK_SENDERS };
|
|
3
|
+
export type WebhookSender = (typeof WEBHOOK_SENDERS)[number];
|
|
4
|
+
/** The senders whose deliveries carry a signature (everything but `none`). */
|
|
5
|
+
export type SigningSender = Exclude<WebhookSender, "none">;
|
|
6
|
+
export declare const SENDER_WORDS: Record<WebhookSender, string>;
|
|
7
|
+
/** Node's `headersDistinct`: every header's values, one per line it arrived on. */
|
|
8
|
+
export type DistinctHeaders = Record<string, string[] | undefined>;
|
|
9
|
+
export type Proof = {
|
|
10
|
+
ok: true;
|
|
11
|
+
signedId: string | null;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
said: string;
|
|
15
|
+
};
|
|
16
|
+
/** A Standard Webhooks timestamp may be this far from now, either way. */
|
|
17
|
+
export declare const STANDARD_TOLERANCE_MS: number;
|
|
18
|
+
/** A `whsec_` secret's key bytes, or null when it isn't one. */
|
|
19
|
+
export declare function standardKey(secret: string): Buffer | null;
|
|
20
|
+
/** Whether a delivery was signed with this secret by this sender. `none` proves nothing and is never passed here. */
|
|
21
|
+
export declare function proveDelivery(sender: SigningSender, secret: string, headers: DistinctHeaders, body: Buffer, now: Date): Proof;
|
|
22
|
+
/** Whether a pasted secret has its sender's shape (Sentry's and Vercel's come from them; the others can be made here). */
|
|
23
|
+
export declare function secretShapeOk(sender: SigningSender, secret: string): boolean;
|
|
24
|
+
/** A new secret for a sender Toolroll can make one for; Sentry's and Vercel's are pasted from them. */
|
|
25
|
+
export declare function makeSecret(sender: "standard" | "github-style"): string;
|
|
26
|
+
/** Whether Toolroll makes this sender's secret (and shows it once), rather than asking for the sender's own. */
|
|
27
|
+
export declare const makesSecret: (sender: SigningSender) => sender is "standard" | "github-style";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a webhook proves who sent it (docs/plans/webhook-tasks.md §1). Each sender's check runs over the raw bytes,
|
|
3
|
+
* before anything is parsed; a security header sent more than once, or not at all, is refused. Nothing here reads or
|
|
4
|
+
* writes the store: the caller passes the current generation's secret, and a refusal writes nothing.
|
|
5
|
+
*/
|
|
6
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { WEBHOOK_SENDERS } from "./contracts/flow.js";
|
|
8
|
+
export { WEBHOOK_SENDERS };
|
|
9
|
+
export const SENDER_WORDS = {
|
|
10
|
+
none: "Unsigned", standard: "Standard Webhooks", "github-style": "GitHub-style signature", sentry: "Sentry", vercel: "Vercel",
|
|
11
|
+
};
|
|
12
|
+
/** A Standard Webhooks timestamp may be this far from now, either way. */
|
|
13
|
+
export const STANDARD_TOLERANCE_MS = 5 * 60_000;
|
|
14
|
+
const HEX = (bytes) => new RegExp(`^[0-9a-f]{${bytes * 2}}$`);
|
|
15
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
16
|
+
const HIDDEN = /[\u0000-\u001f\u007f]/;
|
|
17
|
+
/** Exactly one value for a security header, or why not. */
|
|
18
|
+
function single(headers, name) {
|
|
19
|
+
const values = headers[name] ?? [];
|
|
20
|
+
if (values.length === 0)
|
|
21
|
+
return { ok: false, said: `The ${name} header is missing.` };
|
|
22
|
+
if (values.length > 1)
|
|
23
|
+
return { ok: false, said: `The ${name} header was sent more than once.` };
|
|
24
|
+
return { ok: true, value: values[0] };
|
|
25
|
+
}
|
|
26
|
+
function same(expected, given) {
|
|
27
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
28
|
+
}
|
|
29
|
+
/** A `whsec_` secret's key bytes, or null when it isn't one. */
|
|
30
|
+
export function standardKey(secret) {
|
|
31
|
+
if (!secret.startsWith("whsec_"))
|
|
32
|
+
return null;
|
|
33
|
+
const encoded = secret.slice("whsec_".length);
|
|
34
|
+
if (!BASE64.test(encoded) || encoded.length % 4 !== 0)
|
|
35
|
+
return null;
|
|
36
|
+
const key = Buffer.from(encoded, "base64");
|
|
37
|
+
return key.length >= 24 && key.length <= 64 ? key : null;
|
|
38
|
+
}
|
|
39
|
+
/** Whether a delivery was signed with this secret by this sender. `none` proves nothing and is never passed here. */
|
|
40
|
+
export function proveDelivery(sender, secret, headers, body, now) {
|
|
41
|
+
switch (sender) {
|
|
42
|
+
case "github-style": {
|
|
43
|
+
const header = single(headers, "x-hub-signature-256");
|
|
44
|
+
if (!header.ok)
|
|
45
|
+
return header;
|
|
46
|
+
const given = header.value.trim().toLowerCase();
|
|
47
|
+
if (!given.startsWith("sha256=") || !HEX(32).test(given.slice(7)))
|
|
48
|
+
return { ok: false, said: "The signature isn't sha256=<hex>." };
|
|
49
|
+
return same(createHmac("sha256", secret).update(body).digest(), Buffer.from(given.slice(7), "hex")) ? { ok: true, signedId: null } : { ok: false, said: "Signature doesn't match." };
|
|
50
|
+
}
|
|
51
|
+
case "sentry": {
|
|
52
|
+
const header = single(headers, "sentry-hook-signature");
|
|
53
|
+
if (!header.ok)
|
|
54
|
+
return { ok: false, said: header.said.includes("missing") ? "Sentry didn't sign this: use an Internal Integration's webhook." : header.said };
|
|
55
|
+
const given = header.value.trim().toLowerCase();
|
|
56
|
+
if (!HEX(32).test(given))
|
|
57
|
+
return { ok: false, said: "The signature isn't a SHA-256 hex digest." };
|
|
58
|
+
return same(createHmac("sha256", secret).update(body).digest(), Buffer.from(given, "hex")) ? { ok: true, signedId: null } : { ok: false, said: "Signature doesn't match." };
|
|
59
|
+
}
|
|
60
|
+
case "vercel": {
|
|
61
|
+
const header = single(headers, "x-vercel-signature");
|
|
62
|
+
if (!header.ok)
|
|
63
|
+
return header;
|
|
64
|
+
const given = header.value.trim().toLowerCase();
|
|
65
|
+
if (!HEX(20).test(given))
|
|
66
|
+
return { ok: false, said: "The signature isn't a SHA-1 hex digest." };
|
|
67
|
+
return same(createHmac("sha1", secret).update(body).digest(), Buffer.from(given, "hex")) ? { ok: true, signedId: null } : { ok: false, said: "Signature doesn't match." };
|
|
68
|
+
}
|
|
69
|
+
case "standard": {
|
|
70
|
+
const key = standardKey(secret);
|
|
71
|
+
if (key === null)
|
|
72
|
+
return { ok: false, said: "The saved secret isn't a whsec_ secret." };
|
|
73
|
+
const id = single(headers, "webhook-id"), stamp = single(headers, "webhook-timestamp"), signature = single(headers, "webhook-signature");
|
|
74
|
+
for (const header of [id, stamp, signature])
|
|
75
|
+
if (!header.ok)
|
|
76
|
+
return header;
|
|
77
|
+
const idText = id.value, stampText = stamp.value, signatureText = signature.value;
|
|
78
|
+
if (idText === "" || idText.length > 200 || HIDDEN.test(idText))
|
|
79
|
+
return { ok: false, said: "The webhook-id header isn't usable." };
|
|
80
|
+
// Unix seconds, as an integer: milliseconds, decimals and signs are refused, not guessed at.
|
|
81
|
+
if (!/^\d{1,11}$/.test(stampText))
|
|
82
|
+
return { ok: false, said: "The webhook-timestamp isn't Unix seconds." };
|
|
83
|
+
if (Math.abs(now.getTime() - Number(stampText) * 1000) > STANDARD_TOLERANCE_MS)
|
|
84
|
+
return { ok: false, said: "Too old, or from the future." };
|
|
85
|
+
const expected = createHmac("sha256", key).update(Buffer.concat([Buffer.from(`${idText}.${stampText}.`, "utf8"), body])).digest();
|
|
86
|
+
// Any well-formed v1 entry may match; a malformed entry is passed over, and none well-formed is a refusal.
|
|
87
|
+
let wellFormed = 0;
|
|
88
|
+
for (const entry of signatureText.split(" ")) {
|
|
89
|
+
if (!entry.startsWith("v1,"))
|
|
90
|
+
continue;
|
|
91
|
+
const encoded = entry.slice(3);
|
|
92
|
+
if (!BASE64.test(encoded) || encoded.length % 4 !== 0)
|
|
93
|
+
continue;
|
|
94
|
+
const given = Buffer.from(encoded, "base64");
|
|
95
|
+
if (given.length !== 32)
|
|
96
|
+
continue;
|
|
97
|
+
wellFormed++;
|
|
98
|
+
if (same(expected, given))
|
|
99
|
+
return { ok: true, signedId: idText };
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, said: wellFormed === 0 ? "The webhook-signature has no v1 signature." : "Signature doesn't match." };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Whether a pasted secret has its sender's shape (Sentry's and Vercel's come from them; the others can be made here). */
|
|
106
|
+
export function secretShapeOk(sender, secret) {
|
|
107
|
+
switch (sender) {
|
|
108
|
+
case "sentry": return /^[0-9a-fA-F]{32,128}$/.test(secret);
|
|
109
|
+
case "vercel": return /^[A-Za-z0-9]{16,200}$/.test(secret);
|
|
110
|
+
case "standard": return standardKey(secret) !== null;
|
|
111
|
+
case "github-style": return /^[\x21-\x7e]{16,200}$/.test(secret);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** A new secret for a sender Toolroll can make one for; Sentry's and Vercel's are pasted from them. */
|
|
115
|
+
export function makeSecret(sender) {
|
|
116
|
+
return sender === "standard" ? `whsec_${randomBytes(32).toString("base64")}` : randomBytes(32).toString("hex");
|
|
117
|
+
}
|
|
118
|
+
/** Whether Toolroll makes this sender's secret (and shows it once), rather than asking for the sender's own. */
|
|
119
|
+
export const makesSecret = (sender) => sender === "standard" || sender === "github-style";
|
package/dist/work-index.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type WorkIndexOptions = {
|
|
|
47
47
|
state?: TaskState;
|
|
48
48
|
leadId?: string;
|
|
49
49
|
root?: string;
|
|
50
|
+
/** Only these families (by root task id): the sidebar reads its rows' own words this way. */
|
|
51
|
+
roots?: readonly string[];
|
|
50
52
|
};
|
|
51
53
|
/** How the list is grouped: the Needs you rows by their ask, then building, then the rest. */
|
|
52
54
|
export type WorkIndexGroup = Ask | 'building' | 'rest';
|
|
@@ -85,6 +87,33 @@ export declare function workIndexPage(store: Store, now: Date, access: WorkSumma
|
|
|
85
87
|
* permission-bound and bypasses pagination, never action-time verification. */
|
|
86
88
|
export declare function workIndexTask(store: Store, taskId: string, now: Date, access: WorkSummaryAccess, root?: string): WorkIndexItem | null;
|
|
87
89
|
export declare function workCountsByProject(store: Store, now: Date, access: WorkSummaryAccess): WorkProjectCounts[];
|
|
90
|
+
/** One row per task family the person may see (the Tasks list's own projection: current version, admission, unplaced
|
|
91
|
+
* work and the ceiling as the list applies them), with what the cockpit's states read (agent-state.ts): its code, whether
|
|
92
|
+
* it waits on a person, whether a lead took it on, and its live, unfinished and result runs. Every family, no page. */
|
|
93
|
+
export type WorkStateRow = {
|
|
94
|
+
repo: string | null;
|
|
95
|
+
rootId: string;
|
|
96
|
+
code: string;
|
|
97
|
+
needs: boolean;
|
|
98
|
+
personNeed: boolean;
|
|
99
|
+
leadClaim: boolean;
|
|
100
|
+
liveRun: number | null;
|
|
101
|
+
unfinished: number | null;
|
|
102
|
+
resultRun: number | null;
|
|
103
|
+
/** The Tasks list ranks it as waiting its turn (custody confirmations from another computer included). */
|
|
104
|
+
waits: boolean;
|
|
105
|
+
/** What it needs now, as an identity: its code and the question, hold, result or stop it is about. A new one of
|
|
106
|
+
* these — or a changed plan or approval terms (its digests) — is a new thing to need a person, even on the same task. */
|
|
107
|
+
version: string;
|
|
108
|
+
/** When what it needs now began: the newest of its plan's proposal, its question, its hold, its result and its stop
|
|
109
|
+
* (each fixed once written, so an unrelated edit never moves it). Newer asks are named first. */
|
|
110
|
+
at: string;
|
|
111
|
+
/** The agent its ask is about: the live one, or the one whose question or decision it parked on. */
|
|
112
|
+
askRun: number | null;
|
|
113
|
+
/** Every task_ref of the family (its versions), for counting each of its live attempts. */
|
|
114
|
+
members: number[];
|
|
115
|
+
};
|
|
116
|
+
export declare function workStateRows(store: Store, now: Date, access: WorkSummaryAccess): WorkStateRow[];
|
|
88
117
|
/** Families whose headline is Ready for review, newest first: the Tasks
|
|
89
118
|
* list's own rows, so a completed family (root or any revision), a failed
|
|
90
119
|
* check and a superseded version never count. */
|
package/dist/work-index.js
CHANGED
|
@@ -388,7 +388,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
|
|
|
388
388
|
const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
|
|
389
389
|
const filter = view === 'needs-you' ? 'needs=1' : view === 'running' ? 'family_running=1' : view === 'completed' ? "code='complete'" : '1';
|
|
390
390
|
const rows = prepared(store, `${projection}, selected_page AS MATERIALIZED (
|
|
391
|
-
SELECT * FROM ranked WHERE ${filter} AND ($taskId IS NULL OR root_ref IN (SELECT root_ref FROM members WHERE id=$taskId)) AND ($cursorRoot=0 OR
|
|
391
|
+
SELECT * FROM ranked WHERE ${filter} AND ($taskId IS NULL OR root_ref IN (SELECT root_ref FROM members WHERE id=$taskId)) AND ($roots IS NULL OR root_id IN (SELECT value FROM json_each($roots))) AND ($cursorRoot=0 OR
|
|
392
392
|
${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
|
|
393
393
|
ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
|
|
394
394
|
), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS},${GROUP_TOTALS} FROM ranked), project_totals AS (
|
|
@@ -415,7 +415,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
|
|
|
415
415
|
'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
|
|
416
416
|
'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
|
|
417
417
|
'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
|
|
418
|
-
.all({ ...parameters(now, access, options, custody), $taskId: taskId, $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
|
|
418
|
+
.all({ ...parameters(now, access, options, custody), $taskId: taskId, $roots: options.roots === undefined ? null : JSON.stringify(options.roots), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
|
|
419
419
|
const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
|
|
420
420
|
const page = selected.slice(0, limit), last = page.at(-1);
|
|
421
421
|
const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 2, scope,
|
|
@@ -442,6 +442,20 @@ export function workCountsByProject(store, now, access) {
|
|
|
442
442
|
.all({ ...parameters(now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
|
|
443
443
|
.map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
|
|
444
444
|
}
|
|
445
|
+
export function workStateRows(store, now, access) {
|
|
446
|
+
const projection = registerValidators(store);
|
|
447
|
+
return store.handle.prepare(`${projection} SELECT repo,root_id,code,needs,person_need,lead_claim,live_run,unfinished,result_id,rank,
|
|
448
|
+
code||':'||COALESCE(question_id,'')||':'||COALESCE(agent_ask,'')||':'||COALESCE(hold_id,'')||':'||COALESCE(result_id,'')||':'||COALESCE(stop_run,'')||':'||COALESCE(digest,'')||':'||COALESCE(scope_digest,'') version,
|
|
449
|
+
COALESCE(NULLIF(MAX(COALESCE(proposed_at,''),COALESCE(finished_at,''),
|
|
450
|
+
COALESCE((SELECT created_at FROM decision WHERE id=question_id),''),COALESCE((SELECT asked_at FROM agent_question WHERE id=agent_ask),''),
|
|
451
|
+
COALESCE((SELECT held_at FROM hold WHERE id=hold_id),''),COALESCE((SELECT requested_at FROM run_stop WHERE run=stop_run),'')),''),sort_at) ask_at,
|
|
452
|
+
COALESCE(live_run,(SELECT run FROM decision WHERE id=question_id),(SELECT run FROM agent_question WHERE id=agent_ask)) ask_run,
|
|
453
|
+
(SELECT json_group_array(m.ref_id) FROM members m WHERE m.root_ref=ranked.root_ref) members FROM ranked`)
|
|
454
|
+
.all(parameters(now, access, {}, custodyReadings(store)))
|
|
455
|
+
.map(row => ({ repo: s(row, 'repo'), rootId: String(row['root_id']), code: String(row['code']), needs: n(row, 'needs') === 1, personNeed: n(row, 'person_need') === 1,
|
|
456
|
+
leadClaim: n(row, 'lead_claim') === 1, liveRun: row['live_run'] == null ? null : n(row, 'live_run'), unfinished: row['unfinished'] == null ? null : n(row, 'unfinished'),
|
|
457
|
+
resultRun: row['result_id'] == null ? null : n(row, 'result_id'), waits: Number(row['rank']) === 2, version: String(row['version']), at: String(row['ask_at'] ?? ''), askRun: row['ask_run'] == null ? null : n(row, 'ask_run'), members: JSON.parse(String(row['members'] ?? '[]')).map(Number) }));
|
|
458
|
+
}
|
|
445
459
|
/** Families whose headline is Ready for review, newest first: the Tasks
|
|
446
460
|
* list's own rows, so a completed family (root or any revision), a failed
|
|
447
461
|
* check and a superseded version never count. */
|
|
@@ -6,7 +6,9 @@ const MAX_AGE_MS = 60_000;
|
|
|
6
6
|
const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery', 'sqlite_sequence',
|
|
7
7
|
'team_read', 'team_request', 'push_delivery', 'chat_message_ref',
|
|
8
8
|
// Who is watching an agent right now: renewed by every open page's keep-alive, never news.
|
|
9
|
-
'agent_view'
|
|
9
|
+
'agent_view',
|
|
10
|
+
// A run's How it ran, derived from its own records on first view (run-efficiency.ts): a cache, never news.
|
|
11
|
+
'run_efficiency']);
|
|
10
12
|
const QUIET = {
|
|
11
13
|
runner: ['heartbeat_at'], claim: ['heartbeat_at', 'expires_at'], watch_lease: ['heartbeat_at', 'expires_at'],
|
|
12
14
|
watch_episode: ['ticks'], provider_readiness: ['observed_at'],
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a build may write (docs/plans/agent-baseline-hardening.md arc 2). `workspace`: only its allowlist (agent-fence.ts
|
|
3
|
+
* WriteAllowlist), whatever its permission mode; `anywhere`: anywhere its fence doesn't deny, as builds did before.
|
|
4
|
+
*/
|
|
5
|
+
export type WritesMode = "workspace" | "anywhere";
|
|
6
|
+
export declare const WRITES_DEFAULT: WritesMode;
|
|
7
|
+
export declare const isWritesMode: (value: unknown) => value is WritesMode;
|
|
8
|
+
/** What an approval card and the CLI say of where a build may write (absent: sealed before write allowlists). */
|
|
9
|
+
export declare function writesWords(writes: WritesMode | undefined): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const WRITES_DEFAULT = "workspace";
|
|
2
|
+
export const isWritesMode = (value) => value === "workspace" || value === "anywhere";
|
|
3
|
+
/** What an approval card and the CLI say of where a build may write (absent: sealed before write allowlists). */
|
|
4
|
+
export function writesWords(writes) {
|
|
5
|
+
return writes === "workspace" ? "only its checkout, its caches and its temp folder"
|
|
6
|
+
: writes === "anywhere" ? "anywhere outside Toolroll's fenced folders (an approver's choice)"
|
|
7
|
+
: "anywhere outside Toolroll's fenced folders (approved before write limits)";
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.67",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc -p tsconfig.build.json && node scripts/browser-build.mjs && node scripts/postbuild.mjs",
|
|
10
|
-
"test": "vitest run",
|
|
10
|
+
"test": "vitest run --exclude '**/*.slow.test.ts'",
|
|
11
|
+
"test:slow": "vitest run .slow.test",
|
|
12
|
+
"test:all": "vitest run",
|
|
11
13
|
"test:attacks": "vitest run --config test/attacks/vitest.config.ts",
|
|
12
14
|
"check:quick": "node scripts/quick-check.mjs",
|
|
13
15
|
"dev": "tsx src/cli.ts",
|
|
@@ -80,6 +82,11 @@
|
|
|
80
82
|
"@types/node": "26.6.4",
|
|
81
83
|
"@types/react": "19.3.0",
|
|
82
84
|
"@types/react-dom": "19.3.0",
|
|
85
|
+
"@types/ws": "8.18.2",
|
|
86
|
+
"@xterm/addon-fit": "0.11.0",
|
|
87
|
+
"@xterm/addon-unicode11": "0.9.0",
|
|
88
|
+
"@xterm/addon-webgl": "0.19.0",
|
|
89
|
+
"@xterm/xterm": "6.0.0",
|
|
83
90
|
"@xyflow/react": "12.12.0",
|
|
84
91
|
"class-variance-authority": "0.7.1",
|
|
85
92
|
"clsx": "2.1.1",
|
|
@@ -119,12 +126,18 @@
|
|
|
119
126
|
},
|
|
120
127
|
"dependencies": {
|
|
121
128
|
"@slack/socket-mode": "3.1.0",
|
|
129
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
130
|
+
"@xterm/headless": "6.0.0",
|
|
122
131
|
"discord.js": "14.27.0",
|
|
123
132
|
"imapflow": "2.2.5",
|
|
124
133
|
"mailparser": "3.9.36",
|
|
125
134
|
"nodemailer": "10.0.15",
|
|
126
135
|
"typescript-parser": "npm:typescript@6.0.3",
|
|
136
|
+
"ws": "8.22.0",
|
|
127
137
|
"yaml": "2.9.1",
|
|
128
138
|
"zod": "^4.6.5"
|
|
139
|
+
},
|
|
140
|
+
"optionalDependencies": {
|
|
141
|
+
"@lydell/node-pty": "1.2.0-beta.15"
|
|
129
142
|
}
|
|
130
143
|
}
|