toolroll 0.9.50 → 0.9.52
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 +21 -0
- package/dist/backup.d.ts +8 -0
- package/dist/backup.js +10 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +1 -1
- package/dist/browser-workspace.d.ts +1 -0
- package/dist/database-reclaim.d.ts +4 -0
- package/dist/database-reclaim.js +13 -0
- package/dist/demo.d.ts +1 -1
- package/dist/demo.js +1 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/live-bus.d.ts +3 -3
- package/dist/live-bus.js +3 -3
- package/dist/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +3 -2
- package/dist/monitoring.js +32 -8
- package/dist/notification-delivery.d.ts +19 -0
- package/dist/notification-delivery.js +37 -0
- package/dist/operate-remote-arguments.js +1 -1
- package/dist/operate-remote.js +2 -2
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +54 -120
- package/dist/process-recovery-settlement.js +1 -0
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/retention.js +6 -5
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.js +15 -10
- package/dist/server/people-tokens.js +1 -2
- package/dist/server/remote.js +19 -4
- package/dist/server/settings.js +3 -1
- package/dist/server/shared.d.ts +1 -0
- package/dist/server/shared.js +2 -0
- package/dist/sign-in-guard.d.ts +41 -16
- package/dist/sign-in-guard.js +147 -24
- package/dist/source-key.d.ts +2 -0
- package/dist/source-key.js +49 -0
- package/dist/store.d.ts +38 -43
- package/dist/store.js +255 -134
- package/dist/surface.js +1 -2
- package/dist/team-http.d.ts +3 -0
- package/dist/team-http.js +3 -0
- package/dist/toolroll-update.d.ts +14 -3
- package/dist/toolroll-update.js +105 -14
- package/dist/webhooks.d.ts +10 -22
- package/dist/webhooks.js +43 -81
- package/dist/work-index.js +2 -2
- package/dist/workspace-revision.d.ts +28 -1
- package/dist/workspace-revision.js +276 -49
- package/package.json +1 -3
package/dist/operate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { notificationClaimHeld, claimNotifications, finalizeNotification, notificationDestination } from "./notification-delivery.js";
|
|
1
2
|
import { adapterPolicy } from "./server/route-policy.js";
|
|
2
3
|
import { UNSENT_REPLY_MS } from "./telegram-settings.js";
|
|
3
4
|
import { checkPublicCommand } from "./public-check.js";
|
|
@@ -47,8 +48,6 @@ import { runRequestedUndos, sendTeammateWeeklies } from "./teammate-week.js";
|
|
|
47
48
|
import { refreshConnections } from "./mcp-connect.js";
|
|
48
49
|
import { followDiscord } from "./discord.js";
|
|
49
50
|
import { followTeams } from "./teams.js";
|
|
50
|
-
import { loadDiscordCredentials } from "./discord-api.js";
|
|
51
|
-
import { loadSlackCredentials } from "./slack-api.js";
|
|
52
51
|
import { followSlack } from "./slack.js";
|
|
53
52
|
import { validateScopeText } from "./task-text.js";
|
|
54
53
|
import { runMemoryCommand } from "./memory-cli.js";
|
|
@@ -98,6 +97,7 @@ import { pushPass } from "./push.js";
|
|
|
98
97
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
|
|
99
98
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
|
|
100
99
|
import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
|
|
100
|
+
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
101
101
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
102
102
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
103
103
|
import { createServer as createNetServer } from "node:net";
|
|
@@ -196,7 +196,7 @@ import { isRiskLevel, isTaskSize, legOf, projectRoute, riskConsequence, routeDig
|
|
|
196
196
|
import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
|
|
197
197
|
import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
|
|
198
198
|
import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
|
|
199
|
-
import {
|
|
199
|
+
import { activeWebhookTargets, webhookPass, LEGACY_WEBHOOK_WARNING, loadWebhookTargets, effectivePrimary, isMessagingChannel, loadConsoleUrl, phoneOrigin, saveConsoleUrl, savePrimary } from "./webhooks.js";
|
|
200
200
|
import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
201
201
|
import { attestProvider, attestationOf, versionInRange } from "./attest.js";
|
|
202
202
|
import { recognizesEligible } from "./exhaustion.js";
|
|
@@ -574,21 +574,14 @@ Agents — which provider and model each phase runs on
|
|
|
574
574
|
says REVIEW was not read)
|
|
575
575
|
|
|
576
576
|
The outbox — facts that want a person, durably
|
|
577
|
-
toolroll webhook set slack|discord <url>
|
|
578
|
-
UI-only chat mirrors: every page a
|
|
579
|
-
message with a console link; acting
|
|
580
|
-
stays in the console. Delivers when
|
|
581
|
-
Telegram is not configured.
|
|
582
577
|
toolroll webhook set console-url <http://host:port>
|
|
583
578
|
toolroll webhook primary telegram|slack|discord
|
|
584
579
|
which service receives alerts when
|
|
585
580
|
several are connected (asked once,
|
|
586
581
|
the first time you add a second)
|
|
587
|
-
toolroll webhook status
|
|
588
|
-
toolroll outbox list [--all]
|
|
589
|
-
toolroll outbox deliver --cmd <
|
|
590
|
-
$TOOLROLL_KIND / _SUBJECT / _BODY;
|
|
591
|
-
exit 0 delivered receipts, 1 any fail
|
|
582
|
+
toolroll webhook status
|
|
583
|
+
toolroll outbox list [--all] pending facts (--all: resolved too)
|
|
584
|
+
toolroll outbox deliver --cmd <cmd> deliver through an operator command
|
|
592
585
|
|
|
593
586
|
Runners — the machines that may be given work
|
|
594
587
|
toolroll runner register <name> [--capacity <n>] [--token-file <path>]
|
|
@@ -760,8 +753,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
|
760
753
|
}
|
|
761
754
|
return { positional, flags, repoList };
|
|
762
755
|
}
|
|
763
|
-
/** Route
|
|
756
|
+
/** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
|
|
764
757
|
export async function runOperate(command, argv, write, options = {}) {
|
|
758
|
+
return withPasswordSource(CLI_PASSWORD_SOURCE, () => runOperateLocal(command, argv, write, options));
|
|
759
|
+
}
|
|
760
|
+
async function runOperateLocal(command, argv, write, options = {}) {
|
|
765
761
|
const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
|
|
766
762
|
// A parse error precedes the flags map, so JSON mode is read from the raw
|
|
767
763
|
// argv — the envelope contract holds even for the earliest refusal.
|
|
@@ -1059,8 +1055,8 @@ function telegramConversation(context, options = {}) {
|
|
|
1059
1055
|
evidenceRoot: context.evidenceRoot,
|
|
1060
1056
|
...(context.mateSeams?.subscriptionRunner === undefined ? {} : { subscriptionRunner: context.mateSeams.subscriptionRunner }),
|
|
1061
1057
|
...(context.heldCoordinator === undefined ? {} : { held: context.heldCoordinator }),
|
|
1062
|
-
// Re-read on every card and every `/task`: the same console-url
|
|
1063
|
-
//
|
|
1058
|
+
// Re-read on every card and every `/task`: the same console-url chat
|
|
1059
|
+
// links use, held to an https origin, and — inside `up`, where this
|
|
1064
1060
|
// process also serves the console — equal to that console's own
|
|
1065
1061
|
// `--public-url`, or no link at all.
|
|
1066
1062
|
phoneOrigin: () => phoneOrigin(process.env, dirname(context.databaseFile), { serverOrigin: options.serverOrigin ?? null }),
|
|
@@ -4918,7 +4914,7 @@ async function briefCommand(flags, context) {
|
|
|
4918
4914
|
lines.push(` #${pull.number} ${pull.title}`);
|
|
4919
4915
|
}
|
|
4920
4916
|
if (pending.length > 0) {
|
|
4921
|
-
lines.push(` ▸ OUTBOX ${pending.length} undelivered — toolroll outbox
|
|
4917
|
+
lines.push(` ▸ OUTBOX ${pending.length} undelivered — toolroll outbox list`);
|
|
4922
4918
|
}
|
|
4923
4919
|
if (decisions.length > 0) {
|
|
4924
4920
|
const overdue = decisions.filter(one => one.state === "expired").length;
|
|
@@ -5425,18 +5421,16 @@ async function incidentCommand(positional, flags, context) {
|
|
|
5425
5421
|
]);
|
|
5426
5422
|
}
|
|
5427
5423
|
/**
|
|
5428
|
-
* `toolroll webhook
|
|
5429
|
-
*
|
|
5430
|
-
*
|
|
5431
|
-
* beside the database, or the environment, never anywhere else.
|
|
5424
|
+
* `toolroll webhook status|primary|set console-url` — messaging settings:
|
|
5425
|
+
* which connected chat service receives alerts, and the console address
|
|
5426
|
+
* chat links open.
|
|
5432
5427
|
*/
|
|
5433
|
-
async function webhookCommand(positional,
|
|
5434
|
-
const {
|
|
5428
|
+
async function webhookCommand(positional, _flags, context) {
|
|
5429
|
+
const { write, json } = context;
|
|
5435
5430
|
const dir = dirname(context.databaseFile);
|
|
5436
5431
|
const [action, which, value] = positional;
|
|
5437
5432
|
const telegramConfigured = loadBotToken(process.env, context.telegramTokenFile) !== null;
|
|
5438
5433
|
if (action === undefined || action === "status") {
|
|
5439
|
-
const targets = loadWebhookTargets(process.env, dir);
|
|
5440
5434
|
const consoleUrl = loadConsoleUrl(process.env, dir);
|
|
5441
5435
|
const primary = effectivePrimary(process.env, dir, telegramConfigured);
|
|
5442
5436
|
if (json) {
|
|
@@ -5452,29 +5446,8 @@ async function webhookCommand(positional, flags, context) {
|
|
|
5452
5446
|
write(` Choose: toolroll webhook primary telegram|slack|discord`);
|
|
5453
5447
|
}
|
|
5454
5448
|
write(` links ${consoleUrl ?? "NOT SET — messages will carry no console link; toolroll webhook set console-url http://host:port"}`);
|
|
5455
|
-
if (targets.length === 0) {
|
|
5456
|
-
write("");
|
|
5457
|
-
write(" toolroll webhook set slack https://hooks.slack.com/services/…");
|
|
5458
|
-
write(" toolroll webhook set discord https://discord.com/api/webhooks/…");
|
|
5459
|
-
write(` (or export ${SLACK_ENV} / ${DISCORD_ENV})`);
|
|
5460
|
-
}
|
|
5461
|
-
write("");
|
|
5462
|
-
write(" Mirrors deliver when Telegram is not configured; with a paired Telegram");
|
|
5463
|
-
write(" chat, Telegram carries the page (it can hold buttons) and mirrors stay quiet.");
|
|
5464
5449
|
return EXIT.ok;
|
|
5465
5450
|
}
|
|
5466
|
-
if (action === "test") {
|
|
5467
|
-
const targets = loadWebhookTargets(process.env, dir);
|
|
5468
|
-
if (targets.length === 0) {
|
|
5469
|
-
return fail(write, json, "webhook test", "unconfigured", "no webhook configured — `toolroll webhook set slack|discord <url>`", EXIT.refused);
|
|
5470
|
-
}
|
|
5471
|
-
store.enqueueNotification({ source: { installation: true }, dedupeKey: `webhook-test:${clock().getTime()}`, kind: "test", subject: "toolroll webhook test", body: "If you can read this, the mirror works. Acting happens in the console." }, clock());
|
|
5472
|
-
const report = await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock });
|
|
5473
|
-
if (report.problems.length > 0) {
|
|
5474
|
-
return fail(write, json, "webhook test", "delivery", report.problems.join("; "), EXIT.failed);
|
|
5475
|
-
}
|
|
5476
|
-
return succeed(write, json, "webhook test", { sent: report.sent }, () => [`Sent ${report.sent} message(s). Check the channel.`]);
|
|
5477
|
-
}
|
|
5478
5451
|
if (action === "primary") {
|
|
5479
5452
|
if (which === undefined || !isMessagingChannel(which)) {
|
|
5480
5453
|
return fail(write, json, "webhook primary", "usage", "primary is one of telegram, slack, discord", EXIT.usage);
|
|
@@ -5488,46 +5461,13 @@ async function webhookCommand(positional, flags, context) {
|
|
|
5488
5461
|
`Alerts now go to ${which}. ${which === "telegram" ? "Buttons and replies work there as always." : "Telegram (if configured) still accepts taps and replies — it just stops sending alerts."}`,
|
|
5489
5462
|
]);
|
|
5490
5463
|
}
|
|
5491
|
-
if (action
|
|
5492
|
-
|
|
5493
|
-
return succeed(write, json, "webhook clear", { which }, () => [`${which} mirror cleared.`]);
|
|
5494
|
-
}
|
|
5495
|
-
if (action !== "set" || which === undefined || value === undefined) {
|
|
5496
|
-
return fail(write, json, "webhook", "usage", "`toolroll webhook [status|test|set slack|discord|console-url <value>|clear slack|discord]`", EXIT.usage);
|
|
5497
|
-
}
|
|
5498
|
-
if (which === "console-url") {
|
|
5499
|
-
const saved = saveConsoleUrl(dir, value);
|
|
5500
|
-
if (!saved.ok)
|
|
5501
|
-
return fail(write, json, "webhook set", "invalid", saved.message, EXIT.usage);
|
|
5502
|
-
return succeed(write, json, "webhook set", { which }, () => [`Console links will open ${value.replace(/\/+$/, "")}.`]);
|
|
5503
|
-
}
|
|
5504
|
-
if (which !== "slack" && which !== "discord") {
|
|
5505
|
-
return fail(write, json, "webhook set", "usage", "set what? slack, discord, or console-url", EXIT.usage);
|
|
5464
|
+
if (action !== "set" || which !== "console-url" || value === undefined) {
|
|
5465
|
+
return fail(write, json, "webhook", "usage", "`toolroll webhook [status|primary <service>|set console-url <url>]`", EXIT.usage);
|
|
5506
5466
|
}
|
|
5507
|
-
const saved =
|
|
5467
|
+
const saved = saveConsoleUrl(dir, value);
|
|
5508
5468
|
if (!saved.ok)
|
|
5509
5469
|
return fail(write, json, "webhook set", "invalid", saved.message, EXIT.usage);
|
|
5510
|
-
|
|
5511
|
-
// which one pages — once, right here, not at 3am when both fire.
|
|
5512
|
-
const after = effectivePrimary(process.env, dir, telegramConfigured);
|
|
5513
|
-
let chosen = null;
|
|
5514
|
-
if (after.implicit && loadPrimary(process.env, dir) === null && after.configured.length > 1 && interactive() && !json) {
|
|
5515
|
-
write(`You now have ${after.configured.join(" and ")} connected.`);
|
|
5516
|
-
const answer = (await ask(`Which service should receive alerts? [${after.configured.join("/")}] `)).trim().toLowerCase();
|
|
5517
|
-
if (isMessagingChannel(answer) && after.configured.includes(answer)) {
|
|
5518
|
-
savePrimary(dir, answer);
|
|
5519
|
-
chosen = answer;
|
|
5520
|
-
}
|
|
5521
|
-
else {
|
|
5522
|
-
write(`Left unchosen — ${after.channel} receives alerts by default. Decide any time: toolroll webhook primary <service>`);
|
|
5523
|
-
}
|
|
5524
|
-
}
|
|
5525
|
-
return succeed(write, json, "webhook set", { which, ...(chosen === null ? {} : { primary: chosen }) }, () => [
|
|
5526
|
-
`${which} mirror configured — the URL lives in a private file beside the database.`,
|
|
5527
|
-
...(chosen === null ? [] : [`${chosen} carries the pages.`]),
|
|
5528
|
-
...(after.implicit && chosen === null && !interactive() ? [`Several services are configured — choose the pager: toolroll webhook primary <service>`] : []),
|
|
5529
|
-
`Send yourself a proof: toolroll webhook test`,
|
|
5530
|
-
]);
|
|
5470
|
+
return succeed(write, json, "webhook set", { which }, () => [`Console links will open ${value.replace(/\/+$/, "")}.`]);
|
|
5531
5471
|
}
|
|
5532
5472
|
/**
|
|
5533
5473
|
* `toolroll providers resume <provider>` — a person says the sign-in works
|
|
@@ -7861,6 +7801,7 @@ async function runWatchLoop(args) {
|
|
|
7861
7801
|
let lastTick = 0;
|
|
7862
7802
|
let lastBridge = 0;
|
|
7863
7803
|
let lastPush = 0;
|
|
7804
|
+
let lastWebhook = 0;
|
|
7864
7805
|
let ticks = 0;
|
|
7865
7806
|
let built = 0;
|
|
7866
7807
|
let brokeCount = 0;
|
|
@@ -8156,6 +8097,14 @@ async function runWatchLoop(args) {
|
|
|
8156
8097
|
// Push is additive; a broken pass never stops the watch.
|
|
8157
8098
|
}
|
|
8158
8099
|
}
|
|
8100
|
+
if (now - lastWebhook >= bridgeEveryMs) {
|
|
8101
|
+
lastWebhook = now;
|
|
8102
|
+
const dir = dirname(context.databaseFile);
|
|
8103
|
+
const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
|
|
8104
|
+
const targets = activeWebhookTargets(process.env, dir, primary.channel);
|
|
8105
|
+
if (targets.length > 0)
|
|
8106
|
+
await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
|
|
8107
|
+
}
|
|
8159
8108
|
// The embedded follower owns the wire while it lives; the timer-driven
|
|
8160
8109
|
// pass is the fallback shape for a watch started before a token existed.
|
|
8161
8110
|
if (follower === null && now - lastBridge >= bridgeEveryMs) {
|
|
@@ -8171,12 +8120,6 @@ async function runWatchLoop(args) {
|
|
|
8171
8120
|
quiet.length = 0;
|
|
8172
8121
|
await bridgeCommand(["telegram"], passFlags(primary.channel === "telegram" ? {} : { "inbound-only": "1" }), quietContext);
|
|
8173
8122
|
}
|
|
8174
|
-
if (primary.channel !== null && primary.channel !== "telegram") {
|
|
8175
|
-
const targets = loadWebhookTargets(process.env, dir).filter(one => one.kind === primary.channel && (one.kind !== "slack" || loadSlackCredentials(dir) === null) && (one.kind !== "discord" || loadDiscordCredentials(dir) === null));
|
|
8176
|
-
if (targets.length > 0) {
|
|
8177
|
-
await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
|
|
8178
|
-
}
|
|
8179
|
-
}
|
|
8180
8123
|
}
|
|
8181
8124
|
// Work-conserving: if the world moved while we worked — or we just
|
|
8182
8125
|
// finished something that may have freed a dependent — go again now.
|
|
@@ -8190,7 +8133,7 @@ async function runWatchLoop(args) {
|
|
|
8190
8133
|
// due, a signal lands, or the trial window (--for) ends. Reading one
|
|
8191
8134
|
// integer from SQLite twice a second is the entire idle cost — no
|
|
8192
8135
|
// token has anywhere to be spent from here.
|
|
8193
|
-
const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
|
|
8136
|
+
const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, lastWebhook + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
|
|
8194
8137
|
// Doze for the WHOLE idle window, waking early only for a signal or a
|
|
8195
8138
|
// sequence move — not for one 500ms step and then another pass
|
|
8196
8139
|
// (2026-09-05: that read as two ticks a second, all night, with
|
|
@@ -9800,18 +9743,6 @@ async function publishCommand(positional, flags, context) {
|
|
|
9800
9743
|
return succeed(write, json, "publish", { report }, lines);
|
|
9801
9744
|
}
|
|
9802
9745
|
// ---- the outbox -----------------------------------------------------------
|
|
9803
|
-
/**
|
|
9804
|
-
* `toolroll outbox list|deliver` — reading and draining the durable
|
|
9805
|
-
* outbox. Delivery runs an operator-supplied command once per pending row;
|
|
9806
|
-
* the notification's text reaches it as environment variables, never
|
|
9807
|
-
* substituted into the command line, because subjects and bodies quote
|
|
9808
|
-
* things agents and repositories said and a shell must not meet those.
|
|
9809
|
-
*
|
|
9810
|
-
* toolroll outbox deliver --cmd 'curl -d "$TOOLROLL_SUBJECT" ntfy.sh/mine'
|
|
9811
|
-
*
|
|
9812
|
-
* Exit 0 when everything pending delivered (or nothing was pending);
|
|
9813
|
-
* 1 when any delivery failed — a broken channel is breakage, not a "no".
|
|
9814
|
-
*/
|
|
9815
9746
|
/**
|
|
9816
9747
|
* `toolroll peek [<run>] [--tmux] [--lines <n>]` — watch live agents
|
|
9817
9748
|
* in the terminal: one pane per open run with its stage and transcript
|
|
@@ -9861,6 +9792,11 @@ async function peekCommand(positional, flags, context) {
|
|
|
9861
9792
|
}
|
|
9862
9793
|
return runPeek(store, context.evidenceRoot, { ...(runId === undefined ? {} : { runId }), io: { stdout: process.stdout, stdin: process.stdin }, clock });
|
|
9863
9794
|
}
|
|
9795
|
+
/**
|
|
9796
|
+
* `toolroll outbox list [--all]` — read the durable outbox: the facts that
|
|
9797
|
+
* want a person, pending until resolved. An operator command can also deliver
|
|
9798
|
+
* them with its own destination receipts.
|
|
9799
|
+
*/
|
|
9864
9800
|
async function outboxCommand(positional, flags, context) {
|
|
9865
9801
|
const { store, write, json, clock } = context;
|
|
9866
9802
|
const [action] = positional;
|
|
@@ -9872,17 +9808,12 @@ async function outboxCommand(positional, flags, context) {
|
|
|
9872
9808
|
return EXIT.ok;
|
|
9873
9809
|
}
|
|
9874
9810
|
if (notifications.length === 0) {
|
|
9875
|
-
write(wanted === "pending" ? "Nothing
|
|
9811
|
+
write(wanted === "pending" ? "Nothing pending." : "The outbox is empty.");
|
|
9876
9812
|
return EXIT.ok;
|
|
9877
9813
|
}
|
|
9878
9814
|
for (const one of notifications) {
|
|
9879
|
-
const state = one.deliveredAt !== null
|
|
9880
|
-
? `delivered ${one.deliveredAt}`
|
|
9881
|
-
: one.attempts > 0
|
|
9882
|
-
? `pending, ${one.attempts} failed attempt(s): ${one.lastError ?? ""}`
|
|
9883
|
-
: "pending";
|
|
9884
9815
|
write(` #${one.id} ${one.kind.padEnd(18)} ${one.subject}`);
|
|
9885
|
-
write(` ${
|
|
9816
|
+
write(` ${one.resolvedAt === null ? "pending" : `resolved ${one.resolvedAt}`}`);
|
|
9886
9817
|
}
|
|
9887
9818
|
return EXIT.ok;
|
|
9888
9819
|
}
|
|
@@ -9894,14 +9825,9 @@ async function outboxCommand(positional, flags, context) {
|
|
|
9894
9825
|
if (command === undefined) {
|
|
9895
9826
|
return fail(write, json, "outbox deliver", "usage", "--cmd says how: it runs once per notification, reading $TOOLROLL_KIND, $TOOLROLL_SUBJECT, $TOOLROLL_BODY", EXIT.usage);
|
|
9896
9827
|
}
|
|
9897
|
-
//
|
|
9898
|
-
// outbox, and select-then-send-then-record from two deliverers pages a
|
|
9899
|
-
// person twice. The claim is a short lease on the act of sending; a
|
|
9900
|
-
// deliverer that dies mid-send leaves rows that unclaim by expiry.
|
|
9828
|
+
// The exact command owns a leased destination receipt, including across concurrent CLI invocations.
|
|
9901
9829
|
const owner = `outbox-${randomUUID()}`;
|
|
9902
|
-
// Push
|
|
9903
|
-
// depend on globally-undelivered notifications — Telegram or a webhook
|
|
9904
|
-
// may already have stamped delivered_at.
|
|
9830
|
+
// Push delivery has independent receipts and must keep working if this command fails.
|
|
9905
9831
|
let pushed = 0;
|
|
9906
9832
|
try {
|
|
9907
9833
|
pushed = (await pushPass(store, { configDir: dirname(context.databaseFile), clock })).accepted;
|
|
@@ -9909,7 +9835,7 @@ async function outboxCommand(positional, flags, context) {
|
|
|
9909
9835
|
catch {
|
|
9910
9836
|
// additive; the shell delivery below still runs
|
|
9911
9837
|
}
|
|
9912
|
-
const pending = store
|
|
9838
|
+
const pending = claimNotifications(store, notificationDestination("command", command), owner, clock());
|
|
9913
9839
|
if (pending.length === 0) {
|
|
9914
9840
|
return succeed(write, json, "outbox deliver", { delivered: 0, failed: 0, pushed }, () => [
|
|
9915
9841
|
pushed > 0 ? `Nothing for the shell command; ${pushed} push(es) accepted.` : "Nothing waiting to be delivered.",
|
|
@@ -9918,6 +9844,10 @@ async function outboxCommand(positional, flags, context) {
|
|
|
9918
9844
|
let delivered = 0;
|
|
9919
9845
|
let failed = 0;
|
|
9920
9846
|
for (const one of pending) {
|
|
9847
|
+
if (!notificationClaimHeld(store, one, owner, clock())) {
|
|
9848
|
+
failed++;
|
|
9849
|
+
continue;
|
|
9850
|
+
}
|
|
9921
9851
|
const sent = await run("sh", ["-lc", command], {
|
|
9922
9852
|
timeoutMs: 30_000,
|
|
9923
9853
|
env: {
|
|
@@ -9929,14 +9859,16 @@ async function outboxCommand(positional, flags, context) {
|
|
|
9929
9859
|
});
|
|
9930
9860
|
if (sent.code === 0) {
|
|
9931
9861
|
const receipt = sent.stdout.split("\n")[0]?.trim() ?? "";
|
|
9932
|
-
store
|
|
9933
|
-
|
|
9862
|
+
if (finalizeNotification(store, one, owner, { ok: true, receipt: receipt === "" ? null : receipt }, clock()))
|
|
9863
|
+
delivered++;
|
|
9864
|
+
else
|
|
9865
|
+
failed++;
|
|
9934
9866
|
}
|
|
9935
9867
|
else {
|
|
9936
9868
|
const error = sent.timedOut
|
|
9937
9869
|
? "timed out"
|
|
9938
9870
|
: sent.stderr.split("\n")[0]?.trim() || `exit ${sent.code}`;
|
|
9939
|
-
store
|
|
9871
|
+
finalizeNotification(store, one, owner, { ok: false, error }, clock());
|
|
9940
9872
|
failed++;
|
|
9941
9873
|
}
|
|
9942
9874
|
}
|
|
@@ -10167,6 +10099,7 @@ async function statusCommand(positional, flags, context) {
|
|
|
10167
10099
|
// An update waiting, on what, and the action: before anything else, as it holds up new work.
|
|
10168
10100
|
const waiting = waitingUpdate(context.databaseFile, run => context.store.stopQuiescenceProblem(run) !== null, context.clock());
|
|
10169
10101
|
const updateWaiting = waiting === null ? {} : { updateWaiting: { app: waiting.app, version: waiting.version, stopped: waiting.stopped, run: waiting.run, on: waiting.on, action: waiting.action } };
|
|
10102
|
+
const legacyWebhookWarning = loadWebhookTargets(process.env, dirname(context.databaseFile)).length > 0 ? LEGACY_WEBHOOK_WARNING : null;
|
|
10170
10103
|
const projects = projectBuildsStatus(context);
|
|
10171
10104
|
const projectsLine = projects.length === 0 ? null
|
|
10172
10105
|
: `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
|
|
@@ -10174,7 +10107,7 @@ async function statusCommand(positional, flags, context) {
|
|
|
10174
10107
|
const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
|
|
10175
10108
|
const unsentLine = unsentRepliesLine(unsent);
|
|
10176
10109
|
const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
|
|
10177
|
-
return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
|
|
10110
|
+
return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies, ...(legacyWebhookWarning === null ? {} : { legacyWebhookWarning }) }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine]), ...(legacyWebhookWarning === null ? [] : [legacyWebhookWarning])]);
|
|
10178
10111
|
}
|
|
10179
10112
|
function unsentRepliesLine(unsent) {
|
|
10180
10113
|
if (unsent.length === 0)
|
|
@@ -12825,6 +12758,7 @@ async function backupCommand(positional, flags, context) {
|
|
|
12825
12758
|
return fail(context.write, context.json, command, "failed", `The backup failed: ${made.error}`, EXIT.failed, { backup: made.id });
|
|
12826
12759
|
return succeed(context.write, context.json, command, { backup: made }, () => [
|
|
12827
12760
|
`Backed up to ${made.file} (${bytesWords(made.bytes)}).${made.removed === 0 ? "" : ` Removed ${made.removed} older ${made.removed === 1 ? "backup" : "backups"}.`}`,
|
|
12761
|
+
...(made.checkpoint !== null && "problem" in made.checkpoint ? [`The activity log failed its tamper check, so it was not anchored: ${made.checkpoint.problem}`] : []),
|
|
12828
12762
|
]);
|
|
12829
12763
|
}
|
|
12830
12764
|
const settings = store.backupSettings();
|
|
@@ -123,6 +123,7 @@ export async function recoverPreparedObserverGap(store, input) {
|
|
|
123
123
|
const actionId = store.recordAction({ at: new Date().toISOString(), actor: "system", repo: current.binding.run.repo,
|
|
124
124
|
taskId: current.binding.run.taskId, runId: receipt.targetRun, action: "process absence recovered",
|
|
125
125
|
outcome: digest, source: "work" });
|
|
126
|
+
store.compactRunProcesses(new Date(), receipt.targetRun);
|
|
126
127
|
return { ok: true, recorded: true, ...result, actionId, certificatePath };
|
|
127
128
|
}));
|
|
128
129
|
}
|
package/dist/request-budget.d.ts
CHANGED
|
@@ -107,18 +107,21 @@ export declare const SOURCE_BUDGET_DEFAULTS: Readonly<{
|
|
|
107
107
|
/**
|
|
108
108
|
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
109
109
|
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
110
|
-
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
110
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above. An address
|
|
111
|
+
* key counts by sourceKey (a native IPv6 caller by its /64). Verified identities must explicitly use exact keys.
|
|
111
112
|
*/
|
|
112
113
|
export declare class SourceAdmission {
|
|
113
114
|
private readonly perMinute;
|
|
114
115
|
private readonly clock;
|
|
115
116
|
private readonly tracked;
|
|
116
117
|
private readonly usage;
|
|
118
|
+
private readonly keyMode;
|
|
117
119
|
constructor(options: {
|
|
118
120
|
perMinute: number;
|
|
119
121
|
clock?: () => number;
|
|
120
122
|
tracked?: number;
|
|
123
|
+
keyMode?: "source" | "exact";
|
|
121
124
|
});
|
|
122
125
|
get size(): number;
|
|
123
|
-
admit(
|
|
126
|
+
admit(key: string): Admission;
|
|
124
127
|
}
|
package/dist/request-budget.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sourceKey } from "./source-key.js";
|
|
1
2
|
export const REQUEST_BUDGET_SCHEMA = `
|
|
2
3
|
CREATE TABLE IF NOT EXISTS request_budget_limit (
|
|
3
4
|
target TEXT PRIMARY KEY CHECK (target = '*' OR (length(target) = 12 AND target NOT GLOB '*[^0-9a-f]*')),
|
|
@@ -237,21 +238,24 @@ export const SOURCE_BUDGET_DEFAULTS = Object.freeze({ password: 120, oauthToken:
|
|
|
237
238
|
/**
|
|
238
239
|
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
239
240
|
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
240
|
-
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
241
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above. An address
|
|
242
|
+
* key counts by sourceKey (a native IPv6 caller by its /64). Verified identities must explicitly use exact keys.
|
|
241
243
|
*/
|
|
242
244
|
export class SourceAdmission {
|
|
243
245
|
perMinute;
|
|
244
246
|
clock;
|
|
245
247
|
tracked;
|
|
246
248
|
usage = new Map();
|
|
249
|
+
keyMode;
|
|
247
250
|
constructor(options) {
|
|
251
|
+
this.keyMode = options.keyMode ?? "source";
|
|
248
252
|
this.perMinute = Math.max(1, Math.min(PER_MINUTE_MAX, options.perMinute));
|
|
249
253
|
this.clock = options.clock ?? Date.now;
|
|
250
254
|
this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
|
|
251
255
|
}
|
|
252
256
|
get size() { return this.usage.size; }
|
|
253
|
-
admit(
|
|
254
|
-
const now = this.clock();
|
|
257
|
+
admit(key) {
|
|
258
|
+
const now = this.clock(), source = this.keyMode === "exact" ? key : sourceKey(key);
|
|
255
259
|
let times = this.usage.get(source);
|
|
256
260
|
if (times === undefined) {
|
|
257
261
|
if (this.usage.size >= this.tracked) {
|
package/dist/retention.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reclaimDatabase } from "./database-reclaim.js";
|
|
1
2
|
/**
|
|
2
3
|
* Retention (v105): how long Toolroll keeps run evidence and logs,
|
|
3
4
|
* finished checkouts' records, chat messages and notifications. Until an
|
|
@@ -56,7 +57,7 @@ export const RETENTION_KINDS = [
|
|
|
56
57
|
{ kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
|
|
57
58
|
{ kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
|
|
58
59
|
{ kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
|
|
59
|
-
{ kind: "notifications", label: "Notifications", detail: "
|
|
60
|
+
{ kind: "notifications", label: "Notifications", detail: "Resolved notifications" },
|
|
60
61
|
];
|
|
61
62
|
/** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
|
|
62
63
|
const LONG_CHOICES = [30, 90, 180, 365, 730, null];
|
|
@@ -183,12 +184,11 @@ export function retentionPlan(store, evidenceRoot, now, periods = store.retentio
|
|
|
183
184
|
items.chat = rows.slice(0, SWEEP_BATCH).map(row => ({ id: Number(row["id"]), bytes: Number(row["bytes"]) }));
|
|
184
185
|
}
|
|
185
186
|
if (periods.notifications !== null) {
|
|
186
|
-
const rows = db.prepare(`SELECT n.id AS id, length(n.subject) + length(n.body) +
|
|
187
|
-
WHERE n.created_at < ? AND
|
|
188
|
-
AND NOT (n.claim_owner IS NOT NULL AND n.claim_expires_at > ?)
|
|
187
|
+
const rows = db.prepare(`SELECT n.id AS id, length(n.subject) + length(n.body) + 64 AS bytes FROM notification n
|
|
188
|
+
WHERE n.created_at < ? AND n.resolved_at IS NOT NULL
|
|
189
189
|
AND NOT EXISTS (SELECT 1 FROM notification_delivery d WHERE d.notification = n.id AND d.claim_owner IS NOT NULL AND d.claim_expires_at > ?)
|
|
190
190
|
AND NOT (n.resolved_at IS NULL AND n.task_ref IS NOT NULL AND NOT EXISTS (SELECT 1 FROM task_ref r WHERE r.id = n.task_ref AND ${finishedTask("r")}))
|
|
191
|
-
ORDER BY n.id LIMIT ?`).all(cutoff(now, periods.notifications), now.toISOString(),
|
|
191
|
+
ORDER BY n.id LIMIT ?`).all(cutoff(now, periods.notifications), now.toISOString(), SWEEP_BATCH + 1);
|
|
192
192
|
more.notifications = rows.length > SWEEP_BATCH;
|
|
193
193
|
items.notifications = rows.slice(0, SWEEP_BATCH).map(row => ({ id: Number(row["id"]), bytes: Number(row["bytes"]) }));
|
|
194
194
|
}
|
|
@@ -297,6 +297,7 @@ export function sweepRetention(store, evidenceRoot, now, actor = "worker") {
|
|
|
297
297
|
const counts = plan.counts.map(one => ({ ...one, count: done[one.kind].count, bytes: done[one.kind].bytes }));
|
|
298
298
|
const freed = counts.reduce((sum, one) => sum + one.bytes, 0);
|
|
299
299
|
const ledgerId = store.recordAction({ at: now.toISOString(), actor, repo: null, taskId: null, runId: null, action: "retention sweep", outcome: freed > 0 || counts.some(one => one.count > 0) ? "removed" : "nothing due", source: "policy", detail: sweepWords(counts, freed) });
|
|
300
|
+
reclaimDatabase(store.handle, "retention");
|
|
300
301
|
return { at: now.toISOString(), counts, freed, ledgerId };
|
|
301
302
|
}
|
|
302
303
|
/** Whether a day has passed since the last sweep. */
|
package/dist/scope.d.ts
CHANGED
|
@@ -498,6 +498,7 @@ export declare function authenticateAccount(store: Store, by: string, secret: st
|
|
|
498
498
|
} | {
|
|
499
499
|
ok: false;
|
|
500
500
|
reason: "no-approvers" | "unknown" | "revoked" | "locked";
|
|
501
|
+
retryAfterMs?: number;
|
|
501
502
|
};
|
|
502
503
|
/**
|
|
503
504
|
* v100: someone who signed in with the identity provider moments ago has no
|
package/dist/scope.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
|
|
2
2
|
export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
|
|
3
|
-
import { passwordGuardOf } from "./sign-in-guard.js";
|
|
3
|
+
import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
|
|
4
4
|
import { validateScopeText } from "./task-text.js";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import { projectAuthority } from "./project-access.js";
|
|
@@ -725,16 +725,26 @@ export function authenticateAccount(store, by, secret) {
|
|
|
725
725
|
return { ok: false, reason: "unknown" };
|
|
726
726
|
return { ok: true, role: account.role, generation: account.generation };
|
|
727
727
|
}
|
|
728
|
-
// v99: wrong passwords in a row lock the name for a while, whatever road they came by.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
728
|
+
// v99: wrong passwords in a row lock the name for a while from where they came, whatever road they came by. A
|
|
729
|
+
// locked or held-back source is refused before the password is checked.
|
|
730
|
+
const guard = passwordGuardOf(store), now = Date.now(), source = currentPasswordSource();
|
|
731
|
+
const account = store.accountOf(by), proof = provenPasswordAccount.getStore();
|
|
732
|
+
const proven = proof?.name === by && account !== null && account.revokedAt === null && proof.generation === account.generation;
|
|
733
|
+
const wait = guard.preflight(by, now, source, proven);
|
|
734
|
+
if (wait > 0)
|
|
735
|
+
return { ok: false, reason: "locked", retryAfterMs: wait };
|
|
736
|
+
// Unknown names and legacy minted-token/SSO accounts do the same one KDF as a password account. Throttled
|
|
737
|
+
// attempts above do none, for either name class; never disclose account existence through password work.
|
|
738
|
+
const dummy = `scrypt$${"0".repeat(32)}$${"0".repeat(64)}`;
|
|
739
|
+
const hash = account?.credentialHash ?? dummy;
|
|
740
|
+
if (!hash.startsWith("scrypt$"))
|
|
741
|
+
verifyCredential(dummy, secret);
|
|
742
|
+
const matches = verifyCredential(hash, secret);
|
|
743
|
+
if (account === null || !matches) {
|
|
744
|
+
guard.failed(by, now, source, account !== null);
|
|
735
745
|
return { ok: false, reason: "unknown" };
|
|
736
746
|
}
|
|
737
|
-
guard.succeeded(by);
|
|
747
|
+
guard.succeeded(by, source);
|
|
738
748
|
if (account.revokedAt !== null)
|
|
739
749
|
return { ok: false, reason: "revoked" };
|
|
740
750
|
// Inside a command, the person who signed in is who acted (their own acts never ping them).
|
package/dist/serve.js
CHANGED
|
@@ -117,7 +117,8 @@ import { createPeopleHandlers } from './server/people-tokens.js';
|
|
|
117
117
|
import { createSettingsHandlers } from './server/settings.js';
|
|
118
118
|
import { beatScript, BODY_CAP, chromeScript, decisionsFor, DEMO_BANNER, DEMO_BANNER_SHORT, escape, focusDocument, form, KBD_HELP, LEAD_BY_DEFAULT_FACT, loginHref, matchTaskPath, mateBrowserMessages, mateChatVersion, NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, page, PersistentSessions, pinnedTheme, projectChatHref, QUEUE_VIEW, redactedPath, redirect, refuse, requestContext, respond, safeReturn, screen, SENSITIVE_INPUT, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, shell, SHUTDOWN_WAIT_MS, sidebarScript, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH, ssoStepUps, TASK_FORM_BODY_CAP, taskChatHref, teamProposalCardParts, wrongHostPage } from "./server/shared.js";
|
|
119
119
|
import { createTasksHandlers } from './server/tasks.js';
|
|
120
|
-
import { DEFAULT_GUARD_POLICY, passwordGuardOf, SourceBudget } from "./sign-in-guard.js";
|
|
120
|
+
import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
|
|
121
|
+
import { sourceKey } from "./source-key.js";
|
|
121
122
|
import { readSsoSettings } from "./sso-settings.js";
|
|
122
123
|
import { LEAD_THREAD } from "./store.js";
|
|
123
124
|
import { waitingUpdate } from "./toolroll-update.js";
|
|
@@ -247,7 +248,7 @@ export function createDecisionServer(options) {
|
|
|
247
248
|
// Sessions from before a restart that ran out, or whose account changed since, go now.
|
|
248
249
|
store.sweepWebSessions(Date.now(), SESSION_IDLE_MS, SESSION_ABSOLUTE_MS);
|
|
249
250
|
/** Where a new session signs in from: the browser (its user agent, short) and the address. */
|
|
250
|
-
const arrival = (request) => ({ agent: (request.headers["user-agent"] ?? "").slice(0, 300) || null, address:
|
|
251
|
+
const arrival = (request) => ({ agent: (request.headers["user-agent"] ?? "").slice(0, 300) || null, address: forwardedSourceOf(request).replace(/^fwd:/, "") });
|
|
251
252
|
/** Wrong setup codes left before the first-account road closes. */
|
|
252
253
|
let setupAttemptsLeft = 5;
|
|
253
254
|
// The /join road's limiter (D6; Codex people round 1, finding 4):
|
|
@@ -262,7 +263,7 @@ export function createDecisionServer(options) {
|
|
|
262
263
|
* share the proxy's one bucket. A LOOPBACK peer is that proxy, and only
|
|
263
264
|
* then is the forwarded chain believed, taking the LAST hop (the one
|
|
264
265
|
* the trusted proxy itself appended; earlier entries are client-typed). */
|
|
265
|
-
function
|
|
266
|
+
function forwardedSourceOf(request) {
|
|
266
267
|
const peer = request.socket.remoteAddress ?? "unknown";
|
|
267
268
|
const loopback = LOOPBACK_PEERS.has(peer);
|
|
268
269
|
if (!loopback)
|
|
@@ -272,6 +273,8 @@ export function createDecisionServer(options) {
|
|
|
272
273
|
const lastHop = chain.split(",").pop()?.trim() ?? "";
|
|
273
274
|
return lastHop === "" ? peer : `fwd:${lastHop.slice(0, 64)}`;
|
|
274
275
|
}
|
|
276
|
+
/** That source as every per-source budget counts it: a native IPv6 caller by its /64 (source-key.ts). */
|
|
277
|
+
function joinSourceOf(request) { return sourceKey(forwardedSourceOf(request)); }
|
|
275
278
|
const signInBudget = new SourceBudget();
|
|
276
279
|
const ssoVisits = new Map();
|
|
277
280
|
const ssoHandoffs = new Map();
|
|
@@ -426,7 +429,7 @@ export function createDecisionServer(options) {
|
|
|
426
429
|
/** One-click connections on their way: the service's sign-in page and back, 15 minutes at most. */
|
|
427
430
|
const connectVisits = new Map();
|
|
428
431
|
/** Flows open in a browser (v88): who's here, and a nudge when one changes. */
|
|
429
|
-
// Live views push on write (live-bus.ts): the revision
|
|
432
|
+
// Live views push on write (live-bus.ts): each commit moves the workspace revision, the rooms hear it.
|
|
430
433
|
const liveBus = createLiveBus();
|
|
431
434
|
const liveFollower = followWorkspace(store, () => workspaceRevision.current(), liveBus, { file: store.databaseFile() });
|
|
432
435
|
const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow), { bus: liveBus });
|
|
@@ -455,7 +458,7 @@ export function createDecisionServer(options) {
|
|
|
455
458
|
// Unproved password attempts share a source budget. Only successful proof spends an account budget.
|
|
456
459
|
const sourceClock = options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock };
|
|
457
460
|
const passwordSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
|
|
458
|
-
const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
|
|
461
|
+
const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, keyMode: "exact", ...sourceClock });
|
|
459
462
|
const teamsSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsSource, ...sourceClock });
|
|
460
463
|
const teamsTenantBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsTenant, ...sourceClock });
|
|
461
464
|
const oauthTokenBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.oauthToken, ...sourceClock });
|
|
@@ -476,7 +479,8 @@ export function createDecisionServer(options) {
|
|
|
476
479
|
};
|
|
477
480
|
const server = createServer((request, response) => {
|
|
478
481
|
instrumentRequest(store.telemetry, request, response);
|
|
479
|
-
|
|
482
|
+
// Every password checked while answering counts against where this request came from (sign-in-guard.ts).
|
|
483
|
+
void withPasswordSource(joinSourceOf(request), () => handle(request, response)).catch(error => {
|
|
480
484
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query.
|
|
481
485
|
logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
|
|
482
486
|
if (envValue(process.env, "SERVE_DEBUG") === "1")
|
|
@@ -604,7 +608,7 @@ export function createDecisionServer(options) {
|
|
|
604
608
|
}
|
|
605
609
|
// A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
|
|
606
610
|
// plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
|
|
607
|
-
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource:
|
|
611
|
+
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
|
|
608
612
|
const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
|
|
609
613
|
if (hsts !== null)
|
|
610
614
|
response.setHeader("Strict-Transport-Security", hsts);
|
|
@@ -651,8 +655,9 @@ export function createDecisionServer(options) {
|
|
|
651
655
|
}
|
|
652
656
|
if (who?.via === "bearer" && refuseBudget(admitBearer(request, who)))
|
|
653
657
|
return;
|
|
658
|
+
const passwordProof = who?.via === 'cookie' ? { name: who.name, generation: who.session.generation } : null;
|
|
654
659
|
if (edgeRoute?.domain === 'people')
|
|
655
|
-
return dispatchEdge(edgeRoute, { url, who, request, response, method });
|
|
660
|
+
return provenPasswordAccount.run(passwordProof, () => dispatchEdge(edgeRoute, { url, who, request, response, method }));
|
|
656
661
|
if (who === null) {
|
|
657
662
|
// A GET to an exact task or result (a phone's deep link) signs in and
|
|
658
663
|
// comes back to it — the destination is a same-site path only.
|
|
@@ -679,7 +684,7 @@ export function createDecisionServer(options) {
|
|
|
679
684
|
};
|
|
680
685
|
// An empty password stands for a fresh identity-provider sign-in, for this person only (v100).
|
|
681
686
|
if (method === "GET" || method === "POST")
|
|
682
|
-
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
|
|
687
|
+
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
|
|
683
688
|
const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
|
|
684
689
|
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
685
690
|
const target = actionTarget(url, who, request, body === null ? null : readForm(body, CONSOLE_FORMS.ledgerTarget));
|
|
@@ -749,7 +754,7 @@ export function createDecisionServer(options) {
|
|
|
749
754
|
store.recordAction({ ...entry, at: clock().toISOString(), outcome: "error" });
|
|
750
755
|
throw error;
|
|
751
756
|
}
|
|
752
|
-
}));
|
|
757
|
+
})));
|
|
753
758
|
return respond(response, 405, "text/plain; charset=utf-8", "no such method here");
|
|
754
759
|
}
|
|
755
760
|
function actionTarget(url, who, request, body = null) {
|