toolroll 0.9.63 → 0.9.65
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 +51 -0
- package/README.md +15 -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 +50 -2
- package/dist/agent-fence.js +157 -10
- 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 +36 -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/browser/THIRD_PARTY_NOTICES.txt +48 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +117 -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 +6 -1
- package/dist/builder.js +34 -13
- 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 +12 -4
- package/dist/contracts/console-api.js +12 -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 +17 -0
- package/dist/invoke.js +217 -21
- 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 +258 -0
- package/dist/live-terminal.js +649 -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 +2 -0
- package/dist/operate.js +275 -17
- 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 +12 -0
- package/dist/project-delete-ui.js +41 -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.d.ts +3 -0
- package/dist/release.js +14 -8
- package/dist/request-budget-ui.js +2 -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 +170 -5
- package/dist/scope.js +251 -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 +247 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +6 -1
- package/dist/server/chrome.js +18 -7
- package/dist/server/console.css +52 -38
- 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 +4 -1
- package/dist/server/render-pages.js +5 -4
- 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 +110 -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 +46 -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 +121 -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 +320 -74
- 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 +430 -15
- package/dist/store.js +1041 -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/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 +2 -0
- package/dist/tool-broker.js +1 -0
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- 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/package.json +11 -2
package/dist/server/pages.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { steerControlsFor } from "../steer-controls.js";
|
|
2
|
+
import { canReveal, cdCommandOf, localCheckoutOf } from "../checkout-proof.js";
|
|
3
|
+
import { instanceHttpAnswer } from "../console-instance.js";
|
|
1
4
|
import { handlersOf } from './handler-registry.js';
|
|
2
|
-
import { html, joinHtml, postForm } from "../html.js";
|
|
5
|
+
import { html, joinHtml, postForm, reauth } from "../html.js";
|
|
3
6
|
/** pages handlers, moved without changing their route bodies. */
|
|
4
7
|
import { createHmac, randomBytes } from "node:crypto";
|
|
5
8
|
import { existsSync, opendirSync, readdirSync, realpathSync } from "node:fs";
|
|
@@ -21,7 +24,7 @@ import { run as execRun } from "../exec.js";
|
|
|
21
24
|
import { computeGaps } from "../gaps.js";
|
|
22
25
|
import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
|
|
23
26
|
import { ledgerBody } from "../ledger-view.js";
|
|
24
|
-
import { agentRowOf, agentsBoardOf, agentFactsFor, sealedDiffOf } from "../agents-board.js";
|
|
27
|
+
import { agentLanesOf, agentRowOf, agentsBoardOf, agentFactsFor, laneWindowOf, sealedDiffOf } from "../agents-board.js";
|
|
25
28
|
import { markAgentSeen } from "../agent-board.js";
|
|
26
29
|
import { prometheusMetrics } from "../metrics.js";
|
|
27
30
|
import { readMonitoring } from "../monitoring-settings.js";
|
|
@@ -37,12 +40,14 @@ import { serveInstallAsset } from "./pages-assets.js";
|
|
|
37
40
|
import { healthz } from "./remote-hooks.js";
|
|
38
41
|
import { projectChip, refuse, regionScript, relativeAge, screen, settingsRows, transcriptScript, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
|
|
39
42
|
import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
|
|
43
|
+
import { assignmentOf } from "../assignment.js";
|
|
44
|
+
import { assignmentTaskStatusOf } from "../assignment-presentation.js";
|
|
40
45
|
import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
|
|
41
46
|
import { capsPage } from "./render-settings.js";
|
|
42
47
|
import { decisionAnswerScript, taskBody } from "./render-tasks.js";
|
|
43
48
|
import {} from "./session.js";
|
|
44
49
|
export function createPagesHandlers(runtime) {
|
|
45
|
-
const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, projectOf, lookupSession } = runtime;
|
|
50
|
+
const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
|
|
46
51
|
// The filesystem browser (operator request): pick a project folder by
|
|
47
52
|
// looking, not typing. CONFINED to exactly what opening allows —
|
|
48
53
|
// explicit --repo mode has an enumerable list and gets no browser at
|
|
@@ -382,11 +387,57 @@ export function createPagesHandlers(runtime) {
|
|
|
382
387
|
/** What the board says after a Reply or a steer, in fixed words (never text from the address). */
|
|
383
388
|
const AGENT_SAID = {
|
|
384
389
|
replied: { tone: "info", text: "Reply sent. The agent reads it at its next check." },
|
|
385
|
-
steered: { tone: "info", text: "Note sent.
|
|
390
|
+
steered: { tone: "info", text: "Note sent. It shows in the conversation with whether the agent has read it." },
|
|
391
|
+
"steer-ended": { tone: "error", text: "That attempt ended before your note, so nothing was sent. Your note is kept below." },
|
|
392
|
+
"steered-next": { tone: "info", text: "Note sent. The task's next attempt reads it before starting." },
|
|
393
|
+
"steer-next-finished": { tone: "error", text: "This task is finished, so there's no next attempt to send it to. Your note is kept below." },
|
|
394
|
+
"steer-invalid": { tone: "error", text: "That note will not store. Your note is still in the box." },
|
|
395
|
+
"steer-stale": { tone: "error", text: "That form was already used. Your note is still in the box — send it again." },
|
|
386
396
|
"reply-closed": { tone: "error", text: "That question was already answered." },
|
|
387
397
|
"reply-stale": { tone: "error", text: "This attempt ended before your reply, so nothing was sent." },
|
|
388
398
|
"reply-invalid": { tone: "error", text: "Write a reply of up to 1,500 characters." },
|
|
399
|
+
"steer-no-notes": { tone: "error", text: "This agent takes no notes right now, so nothing was sent. Stop it to change its course." },
|
|
400
|
+
stopping: { tone: "info", text: "Stopping. The work so far is kept." },
|
|
401
|
+
pausing: { tone: "info", text: "Pausing. It stops at the end of its current step." },
|
|
402
|
+
"pausing-with-note": { tone: "info", text: "Pausing. Your note goes to it as soon as it stops." },
|
|
403
|
+
"pause-note-stale": { tone: "error", text: "That form was already used, so your note was not sent. It is still in the box — send it again." },
|
|
404
|
+
resuming: { tone: "info", text: "Resuming. It carries on where it stopped." },
|
|
405
|
+
revealed: { tone: "info", text: "Shown in Finder." },
|
|
406
|
+
"reveal-refused": { tone: "error", text: "That checkout couldn't be shown: it isn't this attempt's running checkout on this computer any more." },
|
|
407
|
+
"reveal-mac-only": { tone: "error", text: "Show in Finder works on a Mac. Copy the command instead." },
|
|
408
|
+
"reveal-failed": { tone: "error", text: "Finder didn't show it. The path is above; copy the command instead." },
|
|
389
409
|
};
|
|
410
|
+
/**
|
|
411
|
+
* The opened agent's head (docs/plans/one-page-task-and-agent.md): its task's status in the task page's own words and
|
|
412
|
+
* that status's one action as a link to the task page tab holding its form, and where this run opens there — a
|
|
413
|
+
* result (the shared predicate: a finished builder or scout run) on the Result tab at this run, a plan on the Plan
|
|
414
|
+
* tab, anything else on its record.
|
|
415
|
+
*/
|
|
416
|
+
function agentTaskHeadOf(taskId, runId, live, access, now) {
|
|
417
|
+
const family = store.taskFamilyOf(taskId, access.repos, access.includeUnplaced);
|
|
418
|
+
const root = family?.root.id ?? taskId;
|
|
419
|
+
const versionOf = (id) => family !== null && id !== family.current.id ? `version=${encodeURIComponent(id)}&` : "";
|
|
420
|
+
const run = store.getRun(runId);
|
|
421
|
+
const open = live || run === null ? null
|
|
422
|
+
: run.finishedAt !== null && (run.role === "builder" || run.role === "scout") ? { label: run.role === "scout" ? "Report" : "Result", href: `${taskHref(root)}?${versionOf(taskId)}tab=result&run=${runId}` }
|
|
423
|
+
: run.role === "planner" ? { label: "Plan", href: `${taskHref(root)}?${versionOf(taskId)}tab=plan` }
|
|
424
|
+
: { label: "Record", href: `/r/${runId}?record=1` };
|
|
425
|
+
let task = null;
|
|
426
|
+
try {
|
|
427
|
+
const assignment = family === null ? null : assignmentOf(store, family.current.id, now, access, evidenceRoot);
|
|
428
|
+
if (assignment !== null) {
|
|
429
|
+
const words = assignmentTaskStatusOf(assignment);
|
|
430
|
+
const wants = words.primaryAction?.href ?? "";
|
|
431
|
+
// A result to review is on the Result tab; everything else the task page heads with (the approval, a retry…).
|
|
432
|
+
const href = /^\/review\?result=|[?&]result=/.test(wants) ? `${taskHref(root)}?tab=result` : taskHref(root);
|
|
433
|
+
task = { headline: words.headline, sentence: words.sentence, tone: words.tone, action: { label: words.primaryAction?.label ?? "Open task", href } };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
task = null;
|
|
438
|
+
}
|
|
439
|
+
return { task, open };
|
|
440
|
+
}
|
|
390
441
|
// The Agents board (one place to check on running agents): rows by state, and the opened agent's live view.
|
|
391
442
|
async function agentsPage(ctx) {
|
|
392
443
|
const { url, who, response, project } = ctx;
|
|
@@ -402,29 +453,43 @@ export function createPagesHandlers(runtime) {
|
|
|
402
453
|
if (facts.outcome !== null && markAgentSeen(store, who.name, facts.runId, now))
|
|
403
454
|
facts.seen = true;
|
|
404
455
|
const live = facts.outcome === null;
|
|
456
|
+
const composer = live ? steerControlsFor(store, facts.taskId, facts.runId, now, who.role === "approver") : null;
|
|
405
457
|
const sealed = live ? null : sealedDiffOf(store, evidenceRoot, facts.runId);
|
|
406
458
|
// A reply's own word first; otherwise the note that the agent asked past the questions cap.
|
|
407
459
|
const said = AGENT_SAID[url.searchParams.get("said") ?? ""] ?? (facts.questionNote === null ? null : { tone: "info", text: facts.questionNote });
|
|
408
460
|
selected = {
|
|
409
461
|
...agentRowOf(store, facts, now, evidenceRoot), live,
|
|
410
462
|
question: live && facts.question !== null ? { ...facts.question, action: `${taskHref(facts.taskId)}/reply` } : null,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
463
|
+
// The conversation's window: its notes answer anywhere; its transcript only from the machine that runs it (the window says so).
|
|
464
|
+
transcript: `/r/${facts.runId}?fragment=transcript&shape=records`,
|
|
465
|
+
// The live look at its checkout needs this machine's runner — the one running it — and pool, as the run page's does.
|
|
466
|
+
peek: live && options.localRunner !== undefined && run.runner === options.localRunner && options.poolRoot !== undefined ? `/r/${facts.runId}?fragment=peek` : null,
|
|
414
467
|
files: (sealed?.files ?? []).slice(0, 30).map(one => ({ path: one.path, added: one.additions, removed: one.deletions })),
|
|
415
468
|
filesMore: Math.max(0, (sealed?.fileCount ?? 0) - 30),
|
|
416
|
-
|
|
469
|
+
// A note goes only to an attempt that reads notes (steer-controls.ts); the composer says what it takes.
|
|
470
|
+
steer: composer?.steer ?? null,
|
|
471
|
+
controls: composer?.controls ?? null,
|
|
472
|
+
checkout: (() => {
|
|
473
|
+
if (!live || who.role !== "approver" || store.isDemo())
|
|
474
|
+
return null;
|
|
475
|
+
const proof = localCheckoutOf(store, facts.runId, { runner: options.localRunner, poolRoot: options.poolRoot }, now);
|
|
476
|
+
// Show in Finder only where it can work (a Mac); elsewhere the path and the command stand alone.
|
|
477
|
+
return proof.ok ? { path: proof.path, cd: cdCommandOf(proof.path), reveal: canReveal(options.revealPath !== undefined) ? `/r/${facts.runId}/reveal` : null } : null;
|
|
478
|
+
})(),
|
|
417
479
|
canAct: who.role === "approver" && !store.isDemo(),
|
|
418
480
|
taskHref: taskHref(facts.taskId),
|
|
419
481
|
// The run's own address: a finished result opens on its result page, anything else on its record.
|
|
420
482
|
resultHref: live ? null : `/r/${facts.runId}`,
|
|
483
|
+
...agentTaskHeadOf(facts.taskId, facts.runId, live, access, now),
|
|
421
484
|
room: `agent:${facts.runId}`, said,
|
|
422
485
|
};
|
|
423
486
|
}
|
|
424
487
|
}
|
|
425
488
|
const groups = agentsBoardOf(store, now, access, who.name, evidenceRoot);
|
|
426
|
-
const
|
|
427
|
-
|
|
489
|
+
const lanes = selected === null ? agentLanesOf(store, now, access, who.name, laneWindowOf(url.searchParams.get("window"))) : null;
|
|
490
|
+
const view = { kind: "agents", groups, selected, lanes,
|
|
491
|
+
// Empty only when nothing ran at all: a planner or review on the timeline is activity too.
|
|
492
|
+
empty: groups.length === 0 && (lanes === null || lanes === undefined) ? "No agents in the last day. They show here the moment one starts." : null };
|
|
428
493
|
const fallback = joinHtml([
|
|
429
494
|
html `<h1>Agents</h1>`,
|
|
430
495
|
groups.length === 0 ? html `<p class="meta">${view.empty ?? ""}</p>` : joinHtml(groups.map(group => joinHtml([
|
|
@@ -456,7 +521,7 @@ export function createPagesHandlers(runtime) {
|
|
|
456
521
|
html `<p class="meta" id="fleet-region-stamp"></p>`,
|
|
457
522
|
html `<h2>Register a worker</h2>`,
|
|
458
523
|
postForm("/fleet/runner/register", html `<label>Name<input type="text" name="name" placeholder="builder-2" maxlength="60" required></label><label>Capacity<input type="text" name="capacity" inputmode="numeric" value="1" aria-label="capacity"></label><label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label><button type="submit">Register — its token is shown once</button>`, { attrs: { class: "card" } }),
|
|
459
|
-
html `<details class="arm-danger"><summary>Retire a worker</summary>${postForm("/fleet/runner/retire", html `<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label
|
|
524
|
+
html `<details class="arm-danger"><summary>Retire a worker</summary>${postForm("/fleet/runner/retire", html `<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label>${reauth(html `<label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label>`)}<button type="submit" class="danger">Retire it</button>`, { attrs: { class: "card" } })}</details>`,
|
|
460
525
|
], "\n"),
|
|
461
526
|
// The password fields make this screen sensitive: sendScreen strips
|
|
462
527
|
// the chrome additions and keeps the reorder poller — the named
|
|
@@ -649,7 +714,8 @@ export function createPagesHandlers(runtime) {
|
|
|
649
714
|
if (who.via !== "cookie")
|
|
650
715
|
return refuse(response, who, 403, "runner registration is a browser surface");
|
|
651
716
|
const token = body.get("token") ?? "";
|
|
652
|
-
|
|
717
|
+
// A worker credential is minted here: a credential act, a password typed now even with Passwordless on.
|
|
718
|
+
if (!authenticateCredential(who, token).ok) {
|
|
653
719
|
return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
|
|
654
720
|
}
|
|
655
721
|
const name = (body.get("name") ?? "").trim();
|
|
@@ -769,7 +835,7 @@ export function createPagesHandlers(runtime) {
|
|
|
769
835
|
// (finding 22/33) — the record is consumed from the RETURNED live
|
|
770
836
|
// session, synchronously, before the first await.
|
|
771
837
|
const password = body.get("token") ?? "";
|
|
772
|
-
if (
|
|
838
|
+
if (!authenticateApprover(who, password).ok) {
|
|
773
839
|
return projectsScreen(response, who, "cloning takes your password, typed again", 403);
|
|
774
840
|
}
|
|
775
841
|
const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
|
|
@@ -879,6 +945,16 @@ export function createPagesHandlers(runtime) {
|
|
|
879
945
|
peeks[path] = peekOf(path);
|
|
880
946
|
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
|
|
881
947
|
}
|
|
948
|
+
async function instanceEdge(ctx) {
|
|
949
|
+
const { request, response, url } = ctx;
|
|
950
|
+
const secret = options.instanceSecret?.() ?? null;
|
|
951
|
+
if (secret === null)
|
|
952
|
+
return respond(response, 404, "application/json; charset=utf-8", JSON.stringify({ error: "not-up" }));
|
|
953
|
+
// Bound to what THIS console saw as the receiving end of this very connection — never the Host header.
|
|
954
|
+
const answer = instanceHttpAnswer(secret, url.searchParams.getAll("c").length === 1 ? url.searchParams.get("c") : null, { address: request.socket.localAddress, port: request.socket.localPort });
|
|
955
|
+
response.setHeader("Cache-Control", "no-store");
|
|
956
|
+
return respond(response, answer.status, "application/json; charset=utf-8", answer.body);
|
|
957
|
+
}
|
|
882
958
|
async function healthzEdge(ctx) {
|
|
883
959
|
const { response, method } = ctx;
|
|
884
960
|
return healthz(store, response, method === 'HEAD');
|
|
@@ -941,6 +1017,7 @@ export function createPagesHandlers(runtime) {
|
|
|
941
1017
|
"projects.onboard-confirm": onboardConfirm,
|
|
942
1018
|
}, {
|
|
943
1019
|
"edge.healthz": healthzEdge,
|
|
1020
|
+
"edge.instance": instanceEdge,
|
|
944
1021
|
"edge.browser-asset": browserAsset,
|
|
945
1022
|
"edge.workspace-style": workspaceStyle,
|
|
946
1023
|
"edge.install-asset": installAsset,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ServerRuntime } from './runtime.js';
|
|
2
2
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
3
|
import { type FormView } from "../contracts/console-api.js";
|
|
4
|
+
/** When the provider checked the person, in ms, if its auth_time is a check made for this visit: present, no earlier
|
|
5
|
+
* than the second the visit began, and not in the future beyond the skew. Null otherwise. */
|
|
6
|
+
export declare function sameVisitCheck(authTime: number | null, startedAt: number, now: number): number | null;
|
|
4
7
|
export declare function createPeopleHandlers(runtime: ServerRuntime): {
|
|
5
8
|
registrations: import("./handler-registry.js").Registration[];
|
|
6
9
|
takeJoinAttempt: (source: string) => boolean;
|
|
@@ -3,7 +3,7 @@ import { adapterPolicy } from "./route-policy.js";
|
|
|
3
3
|
/** settings handlers, moved without changing their route bodies. */
|
|
4
4
|
import { mintApiToken, TOKEN_DAYS } from "../api-tokens.js";
|
|
5
5
|
import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
6
|
-
import { html, htmlString, joinHtml, postForm } from "../html.js";
|
|
6
|
+
import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
|
|
7
7
|
import { credentialsHtml, tokenShownHtml } from "../credentials-ui.js";
|
|
8
8
|
import { projectName } from "../project.js";
|
|
9
9
|
import { requestLimitsHtml, tokenLimitWords } from "../request-budget-ui.js";
|
|
@@ -21,12 +21,26 @@ import { logEvent } from "../log.js";
|
|
|
21
21
|
import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcAuthorizeUrl, verifyIdToken } from "../oidc.js";
|
|
22
22
|
import { personAuditHtml, personHref } from "../people-audit-ui.js";
|
|
23
23
|
import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
|
|
24
|
-
import { hashPassword } from "../scope.js";
|
|
24
|
+
import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
|
|
25
25
|
import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
|
|
26
26
|
import { joinDeadPage, joinFormPage, loginPage, signupPage } from "./render-people.js";
|
|
27
27
|
import { SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
|
|
28
|
+
/** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
|
|
29
|
+
* running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
|
|
30
|
+
* the one allowance backward is that rounding, never a check made before the visit began. */
|
|
31
|
+
const AUTH_TIME_SKEW_MS = 60_000;
|
|
32
|
+
/** When the provider checked the person, in ms, if its auth_time is a check made for this visit: present, no earlier
|
|
33
|
+
* than the second the visit began, and not in the future beyond the skew. Null otherwise. */
|
|
34
|
+
export function sameVisitCheck(authTime, startedAt, now) {
|
|
35
|
+
if (authTime === null || !Number.isFinite(authTime))
|
|
36
|
+
return null;
|
|
37
|
+
const at = authTime * 1000;
|
|
38
|
+
if (at < Math.floor(startedAt / 1000) * 1000 || at > now + AUTH_TIME_SKEW_MS)
|
|
39
|
+
return null;
|
|
40
|
+
return Math.min(Math.max(at, startedAt), now);
|
|
41
|
+
}
|
|
28
42
|
export function createPeopleHandlers(runtime) {
|
|
29
|
-
const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
|
|
43
|
+
const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
|
|
30
44
|
// v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
|
|
31
45
|
async function sessionsPage(ctx) {
|
|
32
46
|
const { url, who, response, project } = ctx;
|
|
@@ -95,7 +109,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
95
109
|
: joinHtml(acts.map(act => html `<p class="row meta">${act.kind} ${act.subject} \u00b7 ${act.at.slice(0, 16).replace("T", " ")}</p>`), "\n"),
|
|
96
110
|
approverView && one.revokedAt === null && one.name !== who.name
|
|
97
111
|
? postForm("/people/revoke", html `\
|
|
98
|
-
|
|
112
|
+
${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password"></label>`)}\
|
|
99
113
|
<button type="submit">Remove ${one.name}'s sign-in</button>\
|
|
100
114
|
<span class="meta">ends their access and everything it signed \u2014 history stays</span>`, { attrs: { class: "row" }, hidden: { name: one.name } })
|
|
101
115
|
: "",
|
|
@@ -105,7 +119,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
105
119
|
const invites = approverView ? store.openInvites(now) : [];
|
|
106
120
|
const inviteRows = invites.map(one => html `<p class="row">${one.role} invite · ${one.projects === null ? "all projects" : one.projects.map(repo => projectName(repo)).join(", ")} \u00b7 from ${personChip(one.mintedBy)} \u00b7 expires ${one.expiresAt.slice(0, 16).replace("T", " ")}${one.attempts > 0 ? ` \u00b7 ${one.attempts} failed attempt${one.attempts === 1 ? "" : "s"}` : ""}\
|
|
107
121
|
${postForm("/people/invite-revoke", html `\
|
|
108
|
-
|
|
122
|
+
${reauth(html `<label>Password <input type="password" name="token" autocomplete="current-password" style="width:8rem"></label>`)}\
|
|
109
123
|
<button type="submit">Cancel it</button>`, { attrs: { style: "display:inline" }, hidden: { id: one.id } })}</p>`);
|
|
110
124
|
const inviteCard = !approverView
|
|
111
125
|
? ""
|
|
@@ -193,10 +207,13 @@ export function createPeopleHandlers(runtime) {
|
|
|
193
207
|
return back("problem", "Choose when it expires.");
|
|
194
208
|
if (access === "act" && who.role !== "approver")
|
|
195
209
|
return back("problem", "A viewer's token can only read.");
|
|
196
|
-
// A token is a credential: made with the person's password (or
|
|
210
|
+
// A token is a credential: made with the person's password typed now (or an explicit identity-provider re-check,
|
|
211
|
+
// spent by it), counted once. Never a remembered password: this act is outside every freshness allowlist.
|
|
197
212
|
const typed = body.get("password") ?? "";
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
// Passwordless never covers it (docs/plans/passwordless.md: a credential act).
|
|
214
|
+
const viewerProof = who.role === "approver" || who.via !== "cookie" ? null : proveAccountNow(store, who.name, typed, { credential: true });
|
|
215
|
+
const confirmed = who.role === "approver" ? authenticateCredential(who, typed).ok
|
|
216
|
+
: viewerProof !== null && viewerProof.ok && credentialProof(viewerProof.method);
|
|
200
217
|
if (!confirmed)
|
|
201
218
|
return back("problem", "Enter your Toolroll password to make a token.");
|
|
202
219
|
const minted = mintApiToken();
|
|
@@ -210,7 +227,8 @@ export function createPeopleHandlers(runtime) {
|
|
|
210
227
|
async function peopleProjects(ctx) {
|
|
211
228
|
const { who, response, now, posted } = ctx;
|
|
212
229
|
const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
|
|
213
|
-
|
|
230
|
+
// Widening access (up to instance operator) is a credential act: a password typed now, Passwordless or not.
|
|
231
|
+
if (!authenticateCredential(who, body.get("token") ?? "", null).ok)
|
|
214
232
|
return refuse(response, who, 403, "Saving access requires your password.", "/people");
|
|
215
233
|
const access = readAccessForm(body);
|
|
216
234
|
if (!access.ok)
|
|
@@ -229,7 +247,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
229
247
|
const token = body.get("token") ?? "";
|
|
230
248
|
// RAISING authority is a password ceremony (the doctrine): adding a
|
|
231
249
|
// person to the instance is exactly that.
|
|
232
|
-
if (!
|
|
250
|
+
if (!authenticateCredential(who, token).ok) {
|
|
233
251
|
return refuse(response, who, 403, "making an invite takes your password, typed again", "/people");
|
|
234
252
|
}
|
|
235
253
|
const role = body.get("role") === "approver" ? "approver" : "viewer";
|
|
@@ -346,7 +364,9 @@ export function createPeopleHandlers(runtime) {
|
|
|
346
364
|
// Bounded: anyone may start a sign-in, so the oldest waiting ones give way.
|
|
347
365
|
while (ssoVisits.size >= 1000)
|
|
348
366
|
ssoVisits.delete(ssoVisits.keys().next().value);
|
|
349
|
-
|
|
367
|
+
const asker = intent === "sign-in" || who?.via !== "cookie" ? null : sessions.hashOf(who.session);
|
|
368
|
+
ssoVisits.set(visit.state, { visit, provider: found.provider, redirect: redirectUri, intent, returnTo, expires: Date.now() + 15 * 60_000, startedAt: Date.now(),
|
|
369
|
+
session: asker === null || who?.via !== "cookie" ? null : { hash: asker, generation: who.session.generation } });
|
|
350
370
|
response.writeHead(302, { location: oidcAuthorizeUrl(found.provider, settings, visit, redirectUri, intent === "reauth"), "cache-control": "no-store", "referrer-policy": "no-referrer",
|
|
351
371
|
"set-cookie": `${SIGN_IN_COOKIE}=${visit.state}; Path=${SSO_CALLBACK}; Max-Age=900; HttpOnly; SameSite=Lax${origin.startsWith("https:") ? "; Secure" : ""}` });
|
|
352
372
|
response.end();
|
|
@@ -371,18 +391,28 @@ export function createPeopleHandlers(runtime) {
|
|
|
371
391
|
if (handoff.intent === "link" || handoff.intent === "reauth") {
|
|
372
392
|
if (who?.via !== "cookie")
|
|
373
393
|
return redirect(response, loginHref(handoff.returnTo));
|
|
394
|
+
// Only the session that asked: same kept session, same credential generation.
|
|
395
|
+
if (handoff.session === null || sessions.hashOf(who.session) !== handoff.session.hash || who.session.generation !== handoff.session.generation) {
|
|
396
|
+
return page(response, 403, loginPage(`That ${settings.label} check was started by another sign-in. Start it again from this page.`, handoff.returnTo, ssoOffer()));
|
|
397
|
+
}
|
|
374
398
|
if (handoff.intent === "link") {
|
|
375
399
|
const linked = store.linkSsoIdentity(who.name, identity, clock());
|
|
376
400
|
if (!linked.ok)
|
|
377
401
|
return redirect(response, `/settings/sign-in?problem=${encodeURIComponent(`That ${settings.label} account signs in as someone else here.`)}`);
|
|
378
|
-
who.session.sso = { at:
|
|
402
|
+
who.session.sso = { at: handoff.checkedAt ?? 0 };
|
|
379
403
|
sessions.persist(who.session);
|
|
380
404
|
return redirect(response, `/settings/sign-in?said=${encodeURIComponent(`This account signs in with ${settings.label} now.`)}`);
|
|
381
405
|
}
|
|
382
406
|
// A step-up: the provider checked the same person again.
|
|
383
407
|
if (store.ssoAccount(identity.issuer, identity.subject) !== who.name)
|
|
384
408
|
return page(response, 403, loginPage(`That ${settings.label} account isn't the one signed in here.`, handoff.returnTo, ssoOffer()));
|
|
385
|
-
who.session.sso = { at:
|
|
409
|
+
who.session.sso = { at: handoff.checkedAt ?? 0 };
|
|
410
|
+
// An explicit re-check on request: the one identity-provider event that stands in for a password typed now (for ONE
|
|
411
|
+
// step-up, spent by it) and may start a freshness window (B2). Kept in memory only: a restart forgets it.
|
|
412
|
+
if (handoff.checkedAt !== null) {
|
|
413
|
+
who.session.ssoReauthAt = handoff.checkedAt;
|
|
414
|
+
who.session.ssoNowAt = handoff.checkedAt;
|
|
415
|
+
}
|
|
386
416
|
sessions.persist(who.session);
|
|
387
417
|
recordSignIn(who.name, "confirmed", settings.label);
|
|
388
418
|
return redirect(response, handoff.returnTo);
|
|
@@ -397,7 +427,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
397
427
|
const account = store.accountOf(signedIn.account);
|
|
398
428
|
const id = randomBytes(32).toString("hex");
|
|
399
429
|
sessions.set(id, { ...arrival(request), name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
|
|
400
|
-
project: defaultProject, projectRevision: 1, sso: { at:
|
|
430
|
+
project: defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
|
|
401
431
|
recordSignIn(signedIn.account, "signed in", settings.label);
|
|
402
432
|
response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
|
|
403
433
|
return redirect(response, handoff.returnTo);
|
|
@@ -447,6 +477,8 @@ export function createPeopleHandlers(runtime) {
|
|
|
447
477
|
lastSeen: Date.now(),
|
|
448
478
|
project: defaultProject,
|
|
449
479
|
projectRevision: 1,
|
|
480
|
+
// A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
|
|
481
|
+
...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
|
|
450
482
|
});
|
|
451
483
|
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
452
484
|
return redirect(response, "/");
|
|
@@ -496,6 +528,9 @@ export function createPeopleHandlers(runtime) {
|
|
|
496
528
|
lastSeen: Date.now(),
|
|
497
529
|
project: defaultProject,
|
|
498
530
|
projectRevision: 1,
|
|
531
|
+
// A password typed to sign in on this computer starts this session's freshness window (cockpit B2); from anywhere
|
|
532
|
+
// else (a phone, a proxy) it signs in and proves nothing more.
|
|
533
|
+
...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
|
|
499
534
|
});
|
|
500
535
|
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
501
536
|
return redirect(response, returnTo);
|
|
@@ -641,8 +676,11 @@ export function createPeopleHandlers(runtime) {
|
|
|
641
676
|
logEvent("warn", "sso.refused", { reason: verified.said });
|
|
642
677
|
return back(verified.said);
|
|
643
678
|
}
|
|
644
|
-
//
|
|
645
|
-
|
|
679
|
+
// When the provider checked the person (its auth_time), proved against this visit (cockpit B2, N18): no earlier than
|
|
680
|
+
// this visit began, and not in the future beyond a minute of clock skew. A step-up must carry one that holds — a
|
|
681
|
+
// check made for this request, never a remembered one; any other sign-in without one signs in, but is never fresh.
|
|
682
|
+
const checkedAt = sameVisitCheck(verified.claims.authTime, held.startedAt, Date.now());
|
|
683
|
+
if (held.intent === "reauth" && checkedAt === null)
|
|
646
684
|
return back(`${settings.label} didn't check you again. Try once more.`);
|
|
647
685
|
const key = randomBytes(24).toString("base64url");
|
|
648
686
|
for (const [one, value] of ssoHandoffs)
|
|
@@ -650,7 +688,7 @@ export function createPeopleHandlers(runtime) {
|
|
|
650
688
|
ssoHandoffs.delete(one);
|
|
651
689
|
while (ssoHandoffs.size >= 1000)
|
|
652
690
|
ssoHandoffs.delete(ssoHandoffs.keys().next().value);
|
|
653
|
-
ssoHandoffs.set(key, { claims: verified.claims, issuer: held.provider.issuer, intent: held.intent, returnTo: held.returnTo, expires: Date.now() + 60_000 });
|
|
691
|
+
ssoHandoffs.set(key, { claims: verified.claims, issuer: held.provider.issuer, intent: held.intent, returnTo: held.returnTo, expires: Date.now() + 60_000, checkedAt, session: held.session });
|
|
654
692
|
const to = `/login/sso/finish?h=${key}`;
|
|
655
693
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
|
|
656
694
|
// The hand-off key is this browser's alone, like the visit's state: its cookie rides to /login/sso/finish.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { adapterPolicy } from "./route-policy.js";
|
|
2
|
-
import { flowFormPage, FORM_PATH, HOOK_PATH, receiveFlowForm, receiveFlowHook } from "../flow-triggers.js";
|
|
2
|
+
import { flowFormPage, FORM_PATH, HOOK_PATH, hookRefusedUnread, receiveFlowForm, receiveFlowHook } from "../flow-triggers.js";
|
|
3
3
|
import { keepPushedUpdate, loadBotToken, pushedByTelegram, telegramHookSecret } from "../telegram.js";
|
|
4
4
|
/** Flow webhooks (v82): the one road a public relay may expose. The secret
|
|
5
5
|
* address proves nothing about the sender on its own for GitHub and Linear:
|
|
@@ -48,15 +48,21 @@ export async function telegramHook(ctx, request, response) {
|
|
|
48
48
|
}
|
|
49
49
|
export async function flowHook(ctx, request, response, url) {
|
|
50
50
|
const { store, options, clock } = ctx;
|
|
51
|
-
const reply = (status, said) => {
|
|
51
|
+
const reply = (status, said, retryAfter) => {
|
|
52
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(retryAfter === undefined ? {} : { "retry-after": String(retryAfter) }) });
|
|
53
|
+
response.end(JSON.stringify({ said }));
|
|
54
|
+
request.resume();
|
|
55
|
+
};
|
|
52
56
|
if (url.pathname.startsWith(FORM_PATH))
|
|
53
57
|
return flowForm(ctx, request, response, url);
|
|
54
58
|
if (options.configDir === undefined || !url.pathname.startsWith(HOOK_PATH))
|
|
55
59
|
return reply(404, "No such address.");
|
|
56
60
|
if (request.method !== "POST")
|
|
57
61
|
return reply(405, "Send a POST.");
|
|
62
|
+
// Refused before it is read (v126): it still counts toward its address's minute, as anything unproven does.
|
|
63
|
+
const refused = (status, said) => { const over = hookRefusedUnread(store, url.pathname.slice(HOOK_PATH.length), clock()); return over === null ? reply(status, said) : reply(over.status, over.said, over.retryAfter); };
|
|
58
64
|
if (Number(request.headers["content-length"] ?? 0) > 1_000_000)
|
|
59
|
-
return
|
|
65
|
+
return refused(413, "Too large.");
|
|
60
66
|
const chunks = [];
|
|
61
67
|
try {
|
|
62
68
|
await new Promise((resolve, reject) => {
|
|
@@ -70,11 +76,11 @@ export async function flowHook(ctx, request, response, url) {
|
|
|
70
76
|
});
|
|
71
77
|
}
|
|
72
78
|
catch (error) {
|
|
73
|
-
return
|
|
79
|
+
return refused(error instanceof Error && error.message === "too-large" ? 413 : 400, "Couldn't read that.");
|
|
74
80
|
}
|
|
75
81
|
try {
|
|
76
|
-
const answer = receiveFlowHook(store, url.pathname.slice(HOOK_PATH.length), { headers: request.
|
|
77
|
-
return reply(answer.status, answer.said);
|
|
82
|
+
const answer = receiveFlowHook(store, url.pathname.slice(HOOK_PATH.length), { headers: request.headersDistinct, body: Buffer.concat(chunks) }, options.configDir, clock());
|
|
83
|
+
return reply(answer.status, answer.said, answer.retryAfter);
|
|
78
84
|
}
|
|
79
85
|
catch {
|
|
80
86
|
return reply(500, "Not saved.");
|
package/dist/server/remote.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FreshFacts } from "../scope.js";
|
|
1
2
|
/** tasks handlers, moved without changing their route bodies. */
|
|
2
3
|
import { type IncomingMessage } from "node:http";
|
|
3
4
|
import { CodingWorkspace } from '../coding-workspace.js';
|
|
@@ -45,11 +46,11 @@ export interface RemoteRuntime {
|
|
|
45
46
|
managedRepos: () => string[];
|
|
46
47
|
joinSourceOf: (request: IncomingMessage) => string;
|
|
47
48
|
ssoSettings: () => import("../oidc.js").OidcSettings | null;
|
|
48
|
-
SSO_FRESH_MS: number;
|
|
49
49
|
oauthTokenBudget: SourceAdmission;
|
|
50
50
|
hookContext: RemoteHookContext;
|
|
51
51
|
teamsSourceBudget: SourceAdmission;
|
|
52
52
|
teamsTenantBudget: SourceAdmission;
|
|
53
|
+
freshFactsOf: (request: IncomingMessage, who: Who | null) => FreshFacts | null;
|
|
53
54
|
}
|
|
54
55
|
export declare function createRemoteHandlers(runtime: RemoteRuntime): {
|
|
55
56
|
registrations: import("./handler-registry.js").Registration[];
|
package/dist/server/remote.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { credentialProof, freshFacts, proveAccountNow, ssoNowAvailable } from "../scope.js";
|
|
1
2
|
import { handlersOf } from './handler-registry.js';
|
|
2
3
|
/** tasks handlers, moved without changing their route bodies. */
|
|
3
4
|
import {} from "node:http";
|
|
@@ -8,7 +9,6 @@ import { createOAuthHttp, resourceMetadataUrl } from "../mcp-oauth.js";
|
|
|
8
9
|
import { reproveRemote } from "../operate-remote.js";
|
|
9
10
|
import { rowVisible } from "../project.js";
|
|
10
11
|
import { RequestBudget, SourceAdmission } from "../request-budget.js";
|
|
11
|
-
import { authenticateAccount } from "../scope.js";
|
|
12
12
|
import { createSessionRunner } from '../session-remote.js';
|
|
13
13
|
import {} from "../store.js";
|
|
14
14
|
import { handleTeamHttp, TEAM_PASSWORD_REVERIFY_MS } from '../team-http.js';
|
|
@@ -17,7 +17,7 @@ import { flowHook, telegramHook } from "./remote-hooks.js";
|
|
|
17
17
|
import { respond } from "./http.js";
|
|
18
18
|
import {} from "./session.js";
|
|
19
19
|
export function createRemoteHandlers(runtime) {
|
|
20
|
-
const { identify, store, admitPasswordSource, admitBearer, allowedHost, team, teamBrowserReply, requestBudget, options, coding, codingProjects, liveCeiling, clock, evidenceRoot, consoleOrigin, managedRepos, joinSourceOf, ssoSettings,
|
|
20
|
+
const { identify, store, admitPasswordSource, admitBearer, allowedHost, team, teamBrowserReply, requestBudget, options, coding, codingProjects, liveCeiling, clock, evidenceRoot, consoleOrigin, managedRepos, joinSourceOf, ssoSettings, oauthTokenBudget, hookContext, teamsSourceBudget, teamsTenantBudget } = runtime;
|
|
21
21
|
/**
|
|
22
22
|
* The full principal a live `so_` API token stands for: its person and their generation, the token's own read/act
|
|
23
23
|
* scope, its project limit narrowed by the person's current access, and its id. Every route that accepts an API token
|
|
@@ -119,8 +119,9 @@ export function createRemoteHandlers(runtime) {
|
|
|
119
119
|
const who = identify(request, false);
|
|
120
120
|
if (who === null || who.via !== "cookie")
|
|
121
121
|
return null;
|
|
122
|
-
return { name: who.name, role: who.role, csrf: who.session.csrf,
|
|
123
|
-
sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider",
|
|
122
|
+
return { name: who.name, role: who.role, csrf: who.session.csrf, generation: who.session.generation,
|
|
123
|
+
sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider",
|
|
124
|
+
fresh: freshFacts.run(runtime.freshFactsOf(request, who), () => ssoNowAvailable(who.name)) } };
|
|
124
125
|
},
|
|
125
126
|
sameSite: request => {
|
|
126
127
|
const origin = request.headers.origin, referer = request.headers.referer;
|
|
@@ -129,7 +130,15 @@ export function createRemoteHandlers(runtime) {
|
|
|
129
130
|
},
|
|
130
131
|
// The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
|
|
131
132
|
// grant (their role, their projects) is checked beside it.
|
|
132
|
-
|
|
133
|
+
// Proved on this request's live session at the moment of consent (signed in here still, same generation), through
|
|
134
|
+
// the one account step-up: a password typed now, or an explicit re-check (spent). Never a remembered password.
|
|
135
|
+
confirm: (request, session, typed) => {
|
|
136
|
+
const who = identify(request, false);
|
|
137
|
+
if (who === null || who.via !== "cookie" || who.name !== session.name || who.session.generation !== session.generation)
|
|
138
|
+
return false;
|
|
139
|
+
// Connecting an outside app is a credential act: never a session (Passwordless) or a remembered password.
|
|
140
|
+
return freshFacts.run(runtime.freshFactsOf(request, who), () => { const proved = proveAccountNow(store, who.name, typed, { credential: true }); return proved.ok && credentialProof(proved.method); });
|
|
141
|
+
},
|
|
133
142
|
projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
|
|
134
143
|
admitToken: request => oauthTokenBudget.admit(joinSourceOf(request)),
|
|
135
144
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** The conversation: chat and lead pages, proposal cards and the task chat. */
|
|
2
|
+
import type { TaskOrigin } from "../task-source.js";
|
|
1
3
|
import { type AssignmentSnapshot } from "../assignment.js";
|
|
2
4
|
import { type BrowserActionCard } from "../browser-workspace.js";
|
|
3
5
|
import { type ChatDraft } from "../converse.js";
|
|
@@ -10,6 +12,7 @@ import { type TaskControlView } from "../task-control.js";
|
|
|
10
12
|
import { type TeamChatProviderResolver } from "../team-chat-authorization.js";
|
|
11
13
|
import { type TeamSnapshot } from "../team-contract.js";
|
|
12
14
|
import { type DisplayStatus } from "../workspace-ui.js";
|
|
15
|
+
import type { LeadQueueRow } from "../lead-queue.js";
|
|
13
16
|
import { type Html } from "../html.js";
|
|
14
17
|
import { type Chrome, type Screen } from "./chrome.js";
|
|
15
18
|
import { type ProjectPeek } from "./render-pages.js";
|
|
@@ -77,6 +80,8 @@ export type TaskChatFocus = {
|
|
|
77
80
|
label: string;
|
|
78
81
|
filedAgo: string | null;
|
|
79
82
|
} | null;
|
|
83
|
+
/** v126: where the work came from (an outside event's line of context). */
|
|
84
|
+
origin?: TaskOrigin | null;
|
|
80
85
|
/** The repair chain row when this task is a machine-drafted repair. */
|
|
81
86
|
repairChain: RepairChainRow | null;
|
|
82
87
|
} | null;
|
|
@@ -214,7 +219,7 @@ export declare function chatPage(chrome: Chrome, data: {
|
|
|
214
219
|
/** Chat's first run is on the page: it already says what to do while there is no lead. */
|
|
215
220
|
firstRunShown?: boolean;
|
|
216
221
|
}): Screen;
|
|
217
|
-
export declare function chatAckPage(chrome: Chrome, turn: ChatTurn, nonce: string): Screen;
|
|
222
|
+
export declare function chatAckPage(chrome: Chrome, turn: ChatTurn, nonce: string, reply?: LeadTurn | null): Screen;
|
|
218
223
|
/** The card that starts a conversation: the one password ceremony (mate arc §1). */
|
|
219
224
|
export declare function leadMintCard(enabled: {
|
|
220
225
|
billing: "metered" | "subscription";
|
|
@@ -301,19 +306,23 @@ export type LeadThreadRows = {
|
|
|
301
306
|
focusTask: TaskChatFocus | null;
|
|
302
307
|
/** The lead's open question to this reader, drawn as buttons under its reply. */
|
|
303
308
|
ask?: LeadAsk | null;
|
|
309
|
+
/** The turn whose question the task has moved past: shown without buttons, marked out of date. */
|
|
310
|
+
staleAsk?: number | null;
|
|
304
311
|
/** Every question the lead asked in these messages, by turn: answered ones show their words without buttons. */
|
|
305
312
|
asks?: Map<number, LeadAsk>;
|
|
313
|
+
/** The person's lead queue rows for these messages, by message id (docs/plans/lead-message-queue.md). */
|
|
314
|
+
queue?: Map<number, LeadQueueRow>;
|
|
306
315
|
};
|
|
307
316
|
/** The lead's question as buttons: each option sends itself as the next message (the same POST as a typed one);
|
|
308
317
|
* "Something else" moves to the composer. Nothing shows while a reply is running. */
|
|
309
318
|
export declare function leadAskHtml(ask: LeadAsk, target: {
|
|
310
319
|
task: string | null;
|
|
311
320
|
project: string | null;
|
|
312
|
-
}, composer: string, open?: boolean): Html;
|
|
321
|
+
}, composer: string, open?: boolean, stale?: boolean): Html;
|
|
313
322
|
/** The thread's messages as the React conversation renders them, each
|
|
314
323
|
* with the proposal cards its turn produced; `back` is where a card's
|
|
315
324
|
* confirm or dismiss returns. */
|
|
316
|
-
export declare function leadBrowserMessages(rows: Pick<LeadThreadRows, "messages" | "proposals" | "decisions" | "pending" | "ask" | "asks">, back: string | null, target?: {
|
|
325
|
+
export declare function leadBrowserMessages(rows: Pick<LeadThreadRows, "messages" | "proposals" | "decisions" | "pending" | "ask" | "staleAsk" | "asks" | "queue">, back: string | null, target?: {
|
|
317
326
|
task: string | null;
|
|
318
327
|
project: string | null;
|
|
319
328
|
}): import("../browser-workspace.js").BrowserMessage[];
|