toolroll 0.9.82 → 0.9.84
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 +41 -0
- package/README.md +4 -3
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +7 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +110 -0
- package/dist/folder-look.js +306 -0
- package/dist/guides.js +4 -3
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +53 -0
- package/dist/operate.js +277 -49
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-identity.d.ts +44 -0
- package/dist/project-identity.js +153 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +91 -17
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +5 -2
- package/dist/server/flows.js +13 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +9 -0
- package/dist/server/pages.js +102 -92
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-pages.d.ts +12 -16
- package/dist/server/render-pages.js +30 -69
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.js +7 -14
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +8 -1
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +211 -71
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/tasks.js +19 -5
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
package/dist/server/settings.js
CHANGED
|
@@ -9,6 +9,7 @@ import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClic
|
|
|
9
9
|
import { learningView } from "../project-learning.js";
|
|
10
10
|
import { skillTestResult } from "../project-skills.js";
|
|
11
11
|
import { discoverTools, projectToolsOf } from "../project-tools.js";
|
|
12
|
+
import { lookProjectMcp } from "../folder-look.js";
|
|
12
13
|
import { publishingOf } from '../pull-request-flow.js';
|
|
13
14
|
import { skillTestFeedbackHtml } from "../skills-ui.js";
|
|
14
15
|
import { labelOf, nameOf } from "../subagent-admin.js";
|
|
@@ -25,7 +26,7 @@ import { modeTermsFromJson } from "../modes.js";
|
|
|
25
26
|
import { envValue } from "../names.js";
|
|
26
27
|
import { REMOTE_MESSAGES } from "../operate-remote.js";
|
|
27
28
|
import { canonicalProject, projectName } from "../project.js";
|
|
28
|
-
import { ALL_CREDENTIAL_ENV } from "../provider.js";
|
|
29
|
+
import { ALL_CREDENTIAL_ENV, MONEY_CAPABILITIES } from "../provider.js";
|
|
29
30
|
import { updateChecksOff } from "../releases.js";
|
|
30
31
|
import { authenticateAccount } from "../scope.js";
|
|
31
32
|
import { budgetLabel, monthOf } from "../spend.js";
|
|
@@ -34,7 +35,7 @@ import { handlersOf } from './handler-registry.js';
|
|
|
34
35
|
import { refuse, screen } from "./chrome.js";
|
|
35
36
|
import { HANDOFF_STYLE, redirect, respond, safeReturn, sayTo, signInSpent, startedHere } from "./http.js";
|
|
36
37
|
import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
|
|
37
|
-
import { browserCaller } from "./session.js";
|
|
38
|
+
import { browserCaller, sessionWithinLifetime } from "./session.js";
|
|
38
39
|
/** settings handlers, moved without changing their route bodies. */
|
|
39
40
|
import { DEFAULT_ACCENT, normalHex } from "../accent-colors.js";
|
|
40
41
|
import { resolvePhaseAgent } from "../agentconfig.js";
|
|
@@ -73,6 +74,9 @@ import { startConnect } from "../mcp-connect.js";
|
|
|
73
74
|
import { decideProposal, listProposals, memoryStatus } from "../memory-pass.js";
|
|
74
75
|
import { checkModels, isNewModel, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "../model-catalog.js";
|
|
75
76
|
import { modelsHtml, modelsScript } from "../models-ui.js";
|
|
77
|
+
import { ALL_SLOTS, applyModelChoices, browserWords, checkScope, DEFAULT_PRESET, isPresetName, newerModels, PRESETS, presetChange, presetInUse, previewModelChange, recentRuns, rowAt, slotKey, SLOTS, slotWords, stateDigest, switchChange } from "../model-choices.js";
|
|
78
|
+
import { HEADROOM_FIVE_HOUR_LIMIT, HEADROOM_WEEKLY_LIMIT, exactModelId } from "../phase-routing.js";
|
|
79
|
+
import { RISKY_MEANS, SIZE_MEANS, SIZING_CLAUDE_MODEL } from "../task-sizing.js";
|
|
76
80
|
import { CHAT_APPROVE_ALL, MODE_MAX_DAYS, modeDigestOf, modeTermsJson, modeWords, presetTerms } from "../modes.js";
|
|
77
81
|
import { monitoringChange, readMonitoring, saveMonitoring } from "../monitoring-settings.js";
|
|
78
82
|
import { monitoringHtml, signingSecretHtml } from "../monitoring-ui.js";
|
|
@@ -108,7 +112,9 @@ import { addProjectInstructions, ASSISTANTS, detectPreparation, modelChoices, pr
|
|
|
108
112
|
import { skillsHtml, skillsScript } from "../skills-ui.js";
|
|
109
113
|
import { checkSlackCredentials, clearSlackCredentials, loadSlackCredentials, saveSlackCredentials, SLACK_MANIFEST, SlackError } from "../slack-api.js";
|
|
110
114
|
import { slackSettingsHtml } from "../slack-settings.js";
|
|
111
|
-
import {
|
|
115
|
+
import { spendHtml, spendSettingsHtml } from "../spend-ui.js";
|
|
116
|
+
import { spendReport, spendReportCsv, spendRows } from "../spend-report.js";
|
|
117
|
+
import { limitsHtml, limitsView } from "../limits-ui.js";
|
|
112
118
|
import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as subagentNamesOf } from "../spend.js";
|
|
113
119
|
import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
|
|
114
120
|
import { ssoSettingsHtml } from "../sso-ui.js";
|
|
@@ -132,8 +138,9 @@ import { personChip, ROLE_TITLES } from "./chrome.js";
|
|
|
132
138
|
import { chatReturnWithSaid, goOutside } from "./http.js";
|
|
133
139
|
import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
|
|
134
140
|
export function createSettingsHandlers(runtime) {
|
|
135
|
-
const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce,
|
|
136
|
-
//
|
|
141
|
+
const { identities, store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, googleVisits } = runtime;
|
|
142
|
+
// What agent work cost, by month: API dollars, plan usage and unknown cost apart (spend-report.ts). An instance
|
|
143
|
+
// operator's page. Its limits show read-only; they change on Settings → Spend.
|
|
137
144
|
async function spendPage(ctx) {
|
|
138
145
|
const { url, who, response, now } = ctx;
|
|
139
146
|
const current = monthOf(now);
|
|
@@ -141,26 +148,17 @@ export function createSettingsHandlers(runtime) {
|
|
|
141
148
|
const month = asked === null ? current : monthNamed(asked);
|
|
142
149
|
if (month === null)
|
|
143
150
|
return refuse(response, who, 400, "Choose a month like 2026-09.", "/spend");
|
|
144
|
-
const
|
|
145
|
-
const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
|
|
151
|
+
const rows = spendRows(store.handle, month.from, month.to);
|
|
146
152
|
const subagentNames = subagentNamesOf(store.handle);
|
|
147
153
|
if (url.searchParams.get("format") === "csv") {
|
|
148
154
|
response.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="standing-orders-spend-${month.name}.csv"`, "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
149
|
-
return void response.end(
|
|
155
|
+
return void response.end(spendReportCsv(rows, subagentNames));
|
|
150
156
|
}
|
|
151
157
|
const shift = (by) => { const at = new Date(`${month.name}-01T00:00:00.000Z`); at.setUTCMonth(at.getUTCMonth() + by); return at.toISOString().slice(0, 7); };
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
items, budgets: budgetStates(store.budgets(), items), subagentNames, csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
157
|
-
targets: [
|
|
158
|
-
{ value: "installation:*", label: "Everything", group: "Everything" },
|
|
159
|
-
...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
|
|
160
|
-
...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
|
|
161
|
-
...subagents.map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
|
|
162
|
-
],
|
|
163
|
-
};
|
|
158
|
+
// Limits read what the budget gate counts today (spend.ts spendItems), for the current month.
|
|
159
|
+
const budgets = month.name === current.name ? budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)) : null;
|
|
160
|
+
const windows = month.name !== current.name ? html `` : limitsHtml(limitsView(store.providerLimits(), [], { project: projectName, subagent: id => subagentNames.get(id) ?? `Subagent ${id}` }, now));
|
|
161
|
+
const view = { month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1), report: spendReport(rows), budgets, windows, subagentNames };
|
|
164
162
|
return sendScreen(response, 200, screen("Spend", spendHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "spend") }));
|
|
165
163
|
}
|
|
166
164
|
async function modePage(ctx) {
|
|
@@ -247,7 +245,13 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
247
245
|
const instructions = previewProjectInstructions(project);
|
|
248
246
|
// The model list as last loaded; Refresh models posts, and the background checks keep it current.
|
|
249
247
|
const catalog = asked === "openrouter" ? modelCatalog.known(readProviderKey("openrouter", providerHome)) ?? { ok: false, problem: "The model list has not loaded yet." } : null;
|
|
250
|
-
|
|
248
|
+
// With nothing saved, setup preselects what the installation builds routine tasks with, else "Sized by job"'s
|
|
249
|
+
// routine builder (Sonnet 5.5) for Claude: an exact id, never a floating name.
|
|
250
|
+
const installationBuild = store.phaseConfig(INSTALLATION_SCOPE, "build");
|
|
251
|
+
const routineDefault = PRESETS[DEFAULT_PRESET].rows["build"] ?? "";
|
|
252
|
+
const preselected = installationBuild?.provider === asked && installationBuild.model !== null && models.some(one => one.value === installationBuild.model) ? installationBuild.model
|
|
253
|
+
: asked === "claude" && models.some(one => one.value === routineDefault) ? routineDefault : models[0]?.value ?? "";
|
|
254
|
+
const inputs = { provider: asked, model: saved?.provider === asked ? saved.model ?? "" : preselected, command: setup?.command ?? (saved === null ? preparation?.command ?? "" : ""), seconds: String((setup?.timeoutMs ?? 300_000) / 1000) };
|
|
251
255
|
return sendScreen(response, 200, screen("Project setup", controlSetupHtml({ repo: project, csrf: who.via === "cookie" ? who.session.csrf : "", provider: asked, inputs, models, connection, catalog, task, preparation,
|
|
252
256
|
instructions: instructions.ok ? { installed: instructions.installed } : { installed: false, message: instructions.message } }), { chrome: chromeFor(project, "settings"), ...(catalog === null ? {} : { functional: { script: openRouterPickerScript() } }) }));
|
|
253
257
|
};
|
|
@@ -300,7 +304,9 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
300
304
|
const now = clock();
|
|
301
305
|
// The page shows the last check and when it ran (docs/plans/get-side-effects.md): Check now, or the watch loop's
|
|
302
306
|
// automatic checks, fetch the lists and may queue a notice; opening the page never does.
|
|
303
|
-
|
|
307
|
+
// Only an instance operator changes models (docs/plans/spend-dashboard.md D4); a preset or a switch is previewed
|
|
308
|
+
// here, read-only, and applied by its own password-checked POST.
|
|
309
|
+
const view = modelsView(who.via === "cookie" && store.isInstanceOperator(who.name), who.via === "cookie" ? who.session.csrf : "", now, url.searchParams.get("said"), url.searchParams.get("problem"), url.searchParams);
|
|
304
310
|
return sendScreen(response, 200, screen("Models", html `<p><a href="/settings">Settings</a></p><h1>Models</h1>${modelsHtml(view)}`, { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
|
|
305
311
|
}
|
|
306
312
|
async function skillsPage(ctx) {
|
|
@@ -866,7 +872,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
866
872
|
async function spendBudget(ctx) {
|
|
867
873
|
const { who, response, now, posted } = ctx;
|
|
868
874
|
const body = readForm(posted, CONSOLE_FORMS.spendBudget);
|
|
869
|
-
const back = sayTo(response, "/spend");
|
|
875
|
+
const back = sayTo(response, "/settings/spend");
|
|
870
876
|
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
871
877
|
return back("problem", "Enter your Toolroll password to change a budget.");
|
|
872
878
|
const target = /^(installation|project|person|subagent):(.+)$/.exec(body.get("target") ?? "");
|
|
@@ -1547,7 +1553,15 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1547
1553
|
const wanted = body.getAll("import");
|
|
1548
1554
|
if (wanted.length === 0)
|
|
1549
1555
|
return back("problem", "Choose at least one tool to add.");
|
|
1550
|
-
|
|
1556
|
+
// The project's own .mcp.json read now, off the console's thread (folder-look.ts).
|
|
1557
|
+
let projectConfig;
|
|
1558
|
+
if (options.identities !== undefined) {
|
|
1559
|
+
const looked = await lookProjectMcp(repo);
|
|
1560
|
+
if (!looked.ok)
|
|
1561
|
+
return back("problem", "This project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.");
|
|
1562
|
+
projectConfig = looked.value;
|
|
1563
|
+
}
|
|
1564
|
+
const found = discoverTools(repo, await codexServers(repo), toolHome, process.env, projectConfig);
|
|
1551
1565
|
const added = [], problems = [];
|
|
1552
1566
|
for (const one of wanted) {
|
|
1553
1567
|
const match = found.find(tool => tool.spec.name === one);
|
|
@@ -1639,38 +1653,6 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1639
1653
|
const updated = await updateRuntime(store, tool, who.name, now, modelSeams);
|
|
1640
1654
|
return back(updated.message, !updated.ok);
|
|
1641
1655
|
}
|
|
1642
|
-
if (action === "agent") {
|
|
1643
|
-
const phase = body.get("phase") ?? "", agent = body.get("agent") ?? "";
|
|
1644
|
-
if (!["plan", "build", "review", "repair"].includes(phase))
|
|
1645
|
-
return back("Choose a role.", true);
|
|
1646
|
-
const role = ROLE_TITLES[phase];
|
|
1647
|
-
if (agent === "inherit") {
|
|
1648
|
-
if (phase !== "repair")
|
|
1649
|
-
return back("Choose a model.", true);
|
|
1650
|
-
store.clearPhaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1651
|
-
return back("Repairs now use the builder's agent.");
|
|
1652
|
-
}
|
|
1653
|
-
const split = agent.indexOf("|");
|
|
1654
|
-
const provider = split < 0 ? "" : agent.slice(0, split), model = split < 0 ? "" : agent.slice(split + 1);
|
|
1655
|
-
if (!isProviderId(provider) || !validModelId(model))
|
|
1656
|
-
return back("Choose a model from the list.", true);
|
|
1657
|
-
const valid = validateSpec({ provider, model });
|
|
1658
|
-
if (!valid.ok)
|
|
1659
|
-
return back(valid.problem, true);
|
|
1660
|
-
if (phase === "review" && provider === "gemini")
|
|
1661
|
-
return back("Gemini cannot review yet. Choose Claude or Codex.", true);
|
|
1662
|
-
const build = store.phaseConfig(INSTALLATION_SCOPE, "build");
|
|
1663
|
-
if (phase === "repair" && build !== null && build.provider !== provider)
|
|
1664
|
-
return back(`Repairs must use the builder's provider (${build.provider}).`, true);
|
|
1665
|
-
store.setPhaseConfig(INSTALLATION_SCOPE, phase, provider, model, who.name, now);
|
|
1666
|
-
const repair = store.phaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1667
|
-
let extra = "";
|
|
1668
|
-
if (phase === "build" && repair !== null && repair.provider !== provider) {
|
|
1669
|
-
store.clearPhaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1670
|
-
extra = " Repairs now follow the builder.";
|
|
1671
|
-
}
|
|
1672
|
-
return back(`${role} now uses ${modelWords(store, provider, model)} for new tasks.${extra}`);
|
|
1673
|
-
}
|
|
1674
1656
|
return refuse(response, who, 404, "No such models action.", "/settings/models");
|
|
1675
1657
|
};
|
|
1676
1658
|
async function knowledgeRefresh(ctx) {
|
|
@@ -1804,7 +1786,8 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1804
1786
|
const nonceKey = `project-setup:${project}`;
|
|
1805
1787
|
if (step === "preview") {
|
|
1806
1788
|
const nonce = mintApprovalNonce(who.name, nonceKey, preview.fingerprint);
|
|
1807
|
-
|
|
1789
|
+
const models = preview.models === null ? null : { label: PRESETS[DEFAULT_PRESET].label, rows: preview.models.rows.map(row => ({ role: slotWords(row.slot), model: row.after?.model ?? (row.slot.phase === "repair" ? "same as the builder" : "not set") })) };
|
|
1790
|
+
return sendScreen(response, 200, screen("Approve project setup", setupPreviewHtml(inputs, { csrf, repo: project, fingerprint: preview.fingerprint, nonce }, models), { chrome: chromeFor(project, "settings") }));
|
|
1808
1791
|
}
|
|
1809
1792
|
if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
|
|
1810
1793
|
return refuse(response, who, 409, "This setup preview expired. Review it again.", "/control");
|
|
@@ -1887,7 +1870,9 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1887
1870
|
if (["scope", "full-access", "cap-usd", "nonce", "digest", "token"].some(key => body.getAll(key).length > 1))
|
|
1888
1871
|
return refuse(response, who, 400, "Submit one value for each field.", "/settings");
|
|
1889
1872
|
const given = (body.get("scope") ?? "all").trim();
|
|
1890
|
-
const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL :
|
|
1873
|
+
const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : identities.vouched(given, consoleProjects) ?? null;
|
|
1874
|
+
if (scope === null)
|
|
1875
|
+
return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
|
|
1891
1876
|
if (scope !== CHAT_APPROVAL_ALL && !(visible(scope) && store.accountCanAccess(who.name, scope)))
|
|
1892
1877
|
return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
|
|
1893
1878
|
const where = scope === CHAT_APPROVAL_ALL ? "all your projects" : projectName(scope);
|
|
@@ -2229,7 +2214,7 @@ ${postForm("/settings/chat-approval/save", html `\
|
|
|
2229
2214
|
const existing = loadBotToken({}, options.telegramTokenFile);
|
|
2230
2215
|
const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
|
|
2231
2216
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
2232
|
-
return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : defaultProject, "settings"), existing, hasEnv, csrf, saved.message, options.configDir === undefined ? null : effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null), null, null, null, {
|
|
2217
|
+
return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : runtime.defaultProject, "settings"), existing, hasEnv, csrf, saved.message, options.configDir === undefined ? null : effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null), null, null, null, {
|
|
2233
2218
|
...store.permissionDefault(),
|
|
2234
2219
|
canManage: who.role === "approver",
|
|
2235
2220
|
}));
|
|
@@ -2422,8 +2407,9 @@ ${modeWords(terms)
|
|
|
2422
2407
|
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
|
|
2423
2408
|
return redirect(response, "/chat");
|
|
2424
2409
|
}
|
|
2425
|
-
/** Settings → Models, read from the saved catalog and the
|
|
2426
|
-
|
|
2410
|
+
/** Settings → Models, read from the saved catalog and the role rows at the scope being edited (the installation,
|
|
2411
|
+
* or one project and what it inherits). */
|
|
2412
|
+
function modelsView(canManage, csrf, now, said, problem, query = new URLSearchParams()) {
|
|
2427
2413
|
const runtimes = runtimeStates(store);
|
|
2428
2414
|
const installed = new Set(runtimes.map(one => one.tool));
|
|
2429
2415
|
const openrouter = (readProviderKey("openrouter", providerHome) ?? process.env["OPENROUTER_API_KEY"] ?? "") !== "";
|
|
@@ -2433,23 +2419,74 @@ ${modeWords(terms)
|
|
|
2433
2419
|
{ label: "Gemini", provider: "gemini", on: installed.has("gemini") },
|
|
2434
2420
|
{ label: "OpenRouter", provider: "openrouter", on: openrouter },
|
|
2435
2421
|
];
|
|
2436
|
-
const
|
|
2437
|
-
const
|
|
2438
|
-
|
|
2439
|
-
|
|
2422
|
+
const projects = consoleProjects();
|
|
2423
|
+
const asked = query.get("scope") ?? INSTALLATION_SCOPE;
|
|
2424
|
+
const scope = asked !== INSTALLATION_SCOPE && projects.includes(asked) ? asked : INSTALLATION_SCOPE;
|
|
2425
|
+
const repo = scope === INSTALLATION_SCOPE ? null : scope;
|
|
2426
|
+
const said_ = (pair) => `${ASSISTANTS[pair.provider]?.name ?? pair.provider} · ${modelWords(store, pair.provider, pair.model)}`;
|
|
2427
|
+
const build = resolvePhaseAgent(store, "build", repo, {});
|
|
2428
|
+
const roles = SLOTS.map(slot => {
|
|
2429
|
+
const own = rowAt(store, scope, slot);
|
|
2430
|
+
const inherited = repo === null ? null : rowAt(store, INSTALLATION_SCOPE, slot);
|
|
2431
|
+
const unset = repo !== null ? (inherited === null ? "Same as the installation (not set)" : `Same as the installation (${inherited.model ?? inherited.provider})`)
|
|
2432
|
+
: slot.phase === "repair" ? "Same as the builder" : slot.phase === "review" ? "Same as the planner"
|
|
2433
|
+
: slot.tier === "light" ? "Not set: small tasks use the routine builder" : slot.tier === "strong" ? "Not set: uses the routine one" : "Not set";
|
|
2434
|
+
const words = own !== null ? said_(own) : inherited !== null ? `Inherits the installation's: ${said_(inherited)}`
|
|
2435
|
+
: slot.phase === "repair" ? "Follows the builder" : slot.phase === "review" ? "Follows the planner" : slot.tier !== "routine" ? "Uses the routine one" : "Not set";
|
|
2436
|
+
const groups = makers.filter(one => one.on && !(slot.phase === "review" && one.provider === "gemini") && !(slot.phase === "repair" && build.ok && one.provider !== build.spec.provider))
|
|
2440
2437
|
.map(one => ({ label: one.label, provider: one.provider, options: modelOptions(store, one.provider, now, providerHome) }));
|
|
2441
|
-
return {
|
|
2442
|
-
phase, label: ROLE_TITLES[phase], groups, ...(phase === "repair" ? { inherit: true } : {}),
|
|
2443
|
-
current: row === null ? (phase === "repair" ? "inherit" : "") : `${row.provider}|${row.model ?? ""}`,
|
|
2444
|
-
words: row === null ? (phase === "repair" ? "Same as the builder" : "Not set") : `${ASSISTANTS[row.provider]?.name ?? row.provider} · ${modelWords(store, row.provider, row.model)}`,
|
|
2445
|
-
};
|
|
2438
|
+
return { key: slotKey(slot), label: slotWords(slot), current: own === null ? "" : `${own.provider}|${own.model ?? ""}`, words, unset, groups, tier: slot.tier !== "routine" };
|
|
2446
2439
|
});
|
|
2440
|
+
const inUse = presetInUse(store, scope);
|
|
2441
|
+
const unchosen = repo === null && ALL_SLOTS.every(slot => rowAt(store, INSTALLATION_SCOPE, slot) === null);
|
|
2442
|
+
const checked = checkScope(store, repo);
|
|
2443
|
+
// A preset or a switch, previewed: the exact rows it moves, with what a change of provider brings along.
|
|
2444
|
+
const pairWords = (pair, slot) => pair === null ? (repo !== null ? "same as the installation" : slot.phase === "repair" ? "same as the builder" : slot.phase === "review" ? "same as the planner" : slot.tier === "light" ? "routine builder" : slot.tier === "strong" ? "routine one" : "not set") : (pair.model ?? `${pair.provider} default`);
|
|
2445
|
+
const switchFrom = query.get("switch"), switchTo = query.get("to");
|
|
2446
|
+
const presetAsked = query.get("preset");
|
|
2447
|
+
// The rows, what they inherit, and both digests come from one snapshot (model-choices.ts previewModelChange).
|
|
2448
|
+
const previewOf = (title, change, hidden) => {
|
|
2449
|
+
const snapshot = previewModelChange(store, scope, change);
|
|
2450
|
+
const rows = snapshot.rows.map(row => {
|
|
2451
|
+
const notes = [];
|
|
2452
|
+
const was = row.before?.provider ?? row.inherited?.provider ?? null;
|
|
2453
|
+
if (row.after !== null && was !== null && was !== row.after.provider && isProviderId(row.after.provider)) {
|
|
2454
|
+
const name = ASSISTANTS[row.after.provider].name;
|
|
2455
|
+
notes.push(`Billed the way ${name} is signed in on this computer.`);
|
|
2456
|
+
notes.push(MONEY_CAPABILITIES[row.after.provider].nativeDollarCapFlag === null ? `${name} can't hold a per-task dollar cap.` : `${name} can hold a per-task dollar cap.`);
|
|
2457
|
+
notes.push(`Builds reach ${name}'s hosts instead of ${ASSISTANTS[was]?.name ?? was}'s.`);
|
|
2458
|
+
}
|
|
2459
|
+
return { role: slotWords(row.slot), before: pairWords(row.before, row.slot), after: pairWords(row.after, row.slot), notes };
|
|
2460
|
+
});
|
|
2461
|
+
return { title, rows, hidden: { scope, digest: snapshot.state, change: snapshot.change, ...hidden } };
|
|
2462
|
+
};
|
|
2463
|
+
const preview = !canManage ? null
|
|
2464
|
+
: isPresetName(presetAsked) ? previewOf(`Use ${PRESETS[presetAsked].label}`, presetChange(presetAsked), { preset: presetAsked })
|
|
2465
|
+
: switchFrom !== null && switchTo !== null && exactModelId(switchFrom) && exactModelId(switchTo) ? previewOf(`Switch to ${switchTo}`, switchChange(store, scope, switchFrom, switchTo), { switch: switchFrom, to: switchTo })
|
|
2466
|
+
// A new installation with nothing chosen yet opens on the default, ready to apply.
|
|
2467
|
+
: unchosen ? previewOf(`Start with ${PRESETS[DEFAULT_PRESET].label}`, presetChange(DEFAULT_PRESET), { preset: DEFAULT_PRESET })
|
|
2468
|
+
: null;
|
|
2447
2469
|
const chat = store.getChatConfig();
|
|
2448
2470
|
const chatName = chat === null ? "" : chat.provider === "claude-subscription" ? "Claude membership" : chat.provider === "codex-subscription" ? "Codex membership" : chat.provider === "anthropic-api" ? "Anthropic API" : "OpenRouter";
|
|
2471
|
+
const capital = (words) => `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
|
|
2472
|
+
const alsoSet = store.listPhaseTierAlternates(INSTALLATION_SCOPE).length + (repo === null ? 0 : store.listPhaseTierAlternates(repo).length) > 0;
|
|
2449
2473
|
return {
|
|
2450
2474
|
runtimes, watch: watchState(store), roles, csrf, canManage, said, problem,
|
|
2451
2475
|
chat: chat === null ? null : { words: `Leads and chat use ${chatName} · ${modelWords(store, chat.provider, chat.model)}` },
|
|
2452
2476
|
fresh: ["claude", "codex", "gemini"].flatMap(source => seenModels(store, source)).filter(model => isNewModel(model, now)).slice(0, 12),
|
|
2477
|
+
scope: { value: scope, label: repo === null ? "All projects" : projectName(repo), digest: stateDigest(store, scope),
|
|
2478
|
+
options: [{ value: INSTALLATION_SCOPE, label: "All projects" }, ...projects.map(one => ({ value: one, label: projectName(one) }))] },
|
|
2479
|
+
inUse: unchosen ? "nothing chosen yet" : inUse === null ? null : PRESETS[inUse].label,
|
|
2480
|
+
presets: ["sized", "opus"].map(name => ({ name, label: PRESETS[name].label, words: PRESETS[name].words, inUse: inUse === name, recommended: name === DEFAULT_PRESET })),
|
|
2481
|
+
newer: newerModels(store, scope).map(one => ({ from: one.from, to: one.to.id, name: one.to.name, roles: one.slots.map(slotWords).join(", ").toLowerCase() })),
|
|
2482
|
+
preview, unresolved: checked.kind === "ok" ? null : checked.kind === "incomplete" ? `some roles have no model yet. Choose them above, or use ${PRESETS[DEFAULT_PRESET].label}.` : browserWords(checked.problem),
|
|
2483
|
+
sizing: { small: capital(SIZE_MEANS.small), routine: capital(SIZE_MEANS.medium), large: SIZE_MEANS.large, risky: RISKY_MEANS,
|
|
2484
|
+
classifier: readProviderKey("openrouter", providerHome) !== null ? "A quick AI check (Jev, through your OpenRouter key) reads its title and description." : `A quick AI check (${SIZING_CLAUDE_MODEL}, on this computer's Claude sign-in) reads its title and description.` },
|
|
2485
|
+
routing: `When a task is filed and Claude has used over ${HEADROOM_FIVE_HOUR_LIMIT}% of its 5-hour window or ${HEADROOM_WEEKLY_LIMIT}% of its week, ${alsoSet ? "the other provider you set for that size is preferred if it has more room" : "an alternate provider with more room would be preferred, but none is set"}; with no room anywhere, the first choice is kept. This is a preference at filing time, not a limit.`,
|
|
2486
|
+
recent: recentRuns(store).map(one => ({ taskId: one.taskId, runId: one.runId, role: one.role,
|
|
2487
|
+
chosen: one.chosen === null ? "its default" : /^(opus|sonnet|haiku|fable)$/.test(one.chosen) ? `floating ${one.chosen}` : one.chosen,
|
|
2488
|
+
ranOn: one.ranOn ?? "not reported" })),
|
|
2489
|
+
waiting: canManage ? store.scopesAwaitingApproval(null, 10, admissionList()).map(one => ({ taskId: one.taskId, title: one.title })) : [],
|
|
2453
2490
|
};
|
|
2454
2491
|
}
|
|
2455
2492
|
/** Google's redirect back: the code becomes a saved refresh token, and a page sends the person back to Settings. */
|
|
@@ -2535,6 +2572,7 @@ ${modeWords(terms)
|
|
|
2535
2572
|
}
|
|
2536
2573
|
const registrations = handlersOf("settings", {
|
|
2537
2574
|
"spend": spendPage,
|
|
2575
|
+
"settings.spend": spendSettingsPage,
|
|
2538
2576
|
"mode": modePage,
|
|
2539
2577
|
"control": controlPage("setup"),
|
|
2540
2578
|
"control.connection": controlPage("connection"),
|
|
@@ -2612,7 +2650,8 @@ ${modeWords(terms)
|
|
|
2612
2650
|
"settings.models-check": modelsAct("check"),
|
|
2613
2651
|
"settings.models-watch": modelsAct("watch"),
|
|
2614
2652
|
"settings.models-update": modelsAct("update"),
|
|
2615
|
-
"settings.models-agent":
|
|
2653
|
+
"settings.models-agent": modelsSave,
|
|
2654
|
+
"settings.models-apply": modelsApply,
|
|
2616
2655
|
"settings.models-act": modelsAct("other"),
|
|
2617
2656
|
"settings.knowledge-refresh": knowledgeRefresh,
|
|
2618
2657
|
"settings.knowledge-proposal": knowledgeProposal,
|
|
@@ -2772,5 +2811,106 @@ ${modeWords(terms)
|
|
|
2772
2811
|
const changed = setReleaseApproval(store, repo, true, who.name, now);
|
|
2773
2812
|
return back("said", changed.changed ? "Releases approve themselves once CI and the release check pass." : "Saved.");
|
|
2774
2813
|
}
|
|
2814
|
+
// Settings → Spend: the monthly limits editor and the models in use. An instance operator's page; changing a limit
|
|
2815
|
+
// posts to /spend/budget with a step-up.
|
|
2816
|
+
async function spendSettingsPage(ctx) {
|
|
2817
|
+
const { url, response, now } = ctx;
|
|
2818
|
+
const current = monthOf(now);
|
|
2819
|
+
const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
|
|
2820
|
+
const subagentNames = subagentNamesOf(store.handle);
|
|
2821
|
+
const projects = consoleProjects();
|
|
2822
|
+
const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
|
|
2823
|
+
const models = ["plan", "build", "review"].map(phase => {
|
|
2824
|
+
const agent = resolvePhaseAgent(store, phase, null, {});
|
|
2825
|
+
return { role: phase === "plan" ? "Planner" : phase === "build" ? "Builder" : "Reviewer", model: agent.ok ? `${agent.spec.provider} · ${agent.spec.model ?? "its default model"}` : "Not set" };
|
|
2826
|
+
});
|
|
2827
|
+
const view = {
|
|
2828
|
+
budgets: budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)), subagentNames, models,
|
|
2829
|
+
targets: [
|
|
2830
|
+
{ value: "installation:*", label: "Everything", group: "Everything" },
|
|
2831
|
+
...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
|
|
2832
|
+
...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
|
|
2833
|
+
...subagents.map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
|
|
2834
|
+
],
|
|
2835
|
+
};
|
|
2836
|
+
return sendScreen(response, 200, screen("Spend settings", spendSettingsHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "settings") }));
|
|
2837
|
+
}
|
|
2838
|
+
// Settings → Models (docs/plans/spend-dashboard.md, slice 3): the one editor. Both posts are an instance operator's,
|
|
2839
|
+
// browser-only step-ups: the password (or a Passwordless session, or an identity-provider re-check) is proved here,
|
|
2840
|
+
// and model-choices.ts re-proves the same session, credential generation and operator standing inside the one
|
|
2841
|
+
// transaction that checks the form against what is stored, validates the result and writes and ledgers every row.
|
|
2842
|
+
function modelsAuthority(who, method) {
|
|
2843
|
+
const session = who.session;
|
|
2844
|
+
return { actor: who.name, generation: session.generation, method, live: () => sessions.holds(session) && sessions.savedNow(session) && sessionWithinLifetime(session, Date.now()) };
|
|
2845
|
+
}
|
|
2846
|
+
function modelsBack(response, scope) {
|
|
2847
|
+
return sayTo(response, `/settings/models${scope === INSTALLATION_SCOPE ? "" : `?scope=${encodeURIComponent(scope)}`}`);
|
|
2848
|
+
}
|
|
2849
|
+
function modelsSaid(result, what) {
|
|
2850
|
+
if (!result.ok)
|
|
2851
|
+
return { key: "problem", words: browserWords(result.problem) };
|
|
2852
|
+
const count = result.diff.length;
|
|
2853
|
+
const done = count === 0 ? "Nothing changed." : `${what}: ${count} change${count === 1 ? "" : "s"} saved for tasks filed from now on.`;
|
|
2854
|
+
return { key: "said", words: result.unresolved === null ? done : `${done} Some roles have no model yet, so new tasks can't be filed here.` };
|
|
2855
|
+
}
|
|
2856
|
+
/** One save of the whole editor: every role at the scope, sets and clears, as one change. */
|
|
2857
|
+
async function modelsSave(ctx) {
|
|
2858
|
+
const { response, now, posted } = ctx;
|
|
2859
|
+
const who = browserCaller(ctx.who);
|
|
2860
|
+
const body = readForm(posted, CONSOLE_FORMS.modelsChange);
|
|
2861
|
+
const scope = body.get("scope") ?? INSTALLATION_SCOPE;
|
|
2862
|
+
const back = modelsBack(response, scope);
|
|
2863
|
+
if (["scope", "password", "digest", ...SLOTS.map(slotKey)].some(field => body.sent.getAll(field).length > 1))
|
|
2864
|
+
return back("problem", "That form was sent twice over. Nothing changed.");
|
|
2865
|
+
const proved = authenticateApprover(who, body.get("password") ?? "");
|
|
2866
|
+
if (!proved.ok)
|
|
2867
|
+
return back("problem", "Enter your Toolroll password to change models.");
|
|
2868
|
+
const change = [];
|
|
2869
|
+
for (const slot of SLOTS) {
|
|
2870
|
+
const value = body.sent.get(slotKey(slot));
|
|
2871
|
+
if (value === null)
|
|
2872
|
+
continue;
|
|
2873
|
+
if (value === "clear") {
|
|
2874
|
+
change.push({ slot, to: null });
|
|
2875
|
+
continue;
|
|
2876
|
+
}
|
|
2877
|
+
const split = value.indexOf("|");
|
|
2878
|
+
if (split < 1)
|
|
2879
|
+
return back("problem", "Choose a model from the list. Nothing changed.");
|
|
2880
|
+
change.push({ slot, to: { provider: value.slice(0, split), model: value.slice(split + 1) } });
|
|
2881
|
+
}
|
|
2882
|
+
// Repairs resume the builder's session: a builder moved to another provider takes repair with it.
|
|
2883
|
+
const builder = change.find(one => one.slot.phase === "build" && one.slot.tier === "routine")?.to ?? null;
|
|
2884
|
+
const repair = change.findIndex(one => one.slot.phase === "repair");
|
|
2885
|
+
let follows = false;
|
|
2886
|
+
if (builder !== null && repair >= 0 && change[repair].to !== null && change[repair].to.provider !== builder.provider) {
|
|
2887
|
+
change[repair] = { slot: change[repair].slot, to: null };
|
|
2888
|
+
follows = true;
|
|
2889
|
+
}
|
|
2890
|
+
const result = applyModelChoices(store, { scope, change, expected: { state: body.get("digest") ?? "" }, strict: true, knownProject: repo => consoleProjects().includes(repo) }, modelsAuthority(who, proved.method), now);
|
|
2891
|
+
const said = modelsSaid(result, scope === INSTALLATION_SCOPE ? "All projects" : projectName(scope));
|
|
2892
|
+
return back(said.key, follows && result.ok ? `${said.words} Repairs now follow the builder.` : said.words);
|
|
2893
|
+
}
|
|
2894
|
+
/** A preset or a switch, applied exactly as previewed: the change's own digest rides the form. */
|
|
2895
|
+
async function modelsApply(ctx) {
|
|
2896
|
+
const { response, now, posted } = ctx;
|
|
2897
|
+
const who = browserCaller(ctx.who);
|
|
2898
|
+
const body = readForm(posted, CONSOLE_FORMS.modelsChange);
|
|
2899
|
+
const scope = body.get("scope") ?? INSTALLATION_SCOPE;
|
|
2900
|
+
const back = modelsBack(response, scope);
|
|
2901
|
+
if (["scope", "password", "digest", "preset", "switch", "to", "change"].some(field => body.sent.getAll(field).length > 1))
|
|
2902
|
+
return back("problem", "That form was sent twice over. Nothing changed.");
|
|
2903
|
+
const proved = authenticateApprover(who, body.get("password") ?? "");
|
|
2904
|
+
if (!proved.ok)
|
|
2905
|
+
return back("problem", "Enter your Toolroll password to change models.");
|
|
2906
|
+
const preset = body.get("preset"), from = body.get("switch"), to = body.get("to");
|
|
2907
|
+
const change = isPresetName(preset) ? presetChange(preset)
|
|
2908
|
+
: from !== null && to !== null && exactModelId(from) && exactModelId(to) ? switchChange(store, scope, from, to) : null;
|
|
2909
|
+
if (change === null)
|
|
2910
|
+
return back("problem", "Choose a preset or a newer model. Nothing changed.");
|
|
2911
|
+
const result = applyModelChoices(store, { scope, change, expected: { state: body.get("digest") ?? "", change: body.get("change") ?? "" }, strict: true, knownProject: repo => consoleProjects().includes(repo) }, modelsAuthority(who, proved.method), now);
|
|
2912
|
+
const said = modelsSaid(result, isPresetName(preset) ? PRESETS[preset].label : `Switched to ${to}`);
|
|
2913
|
+
return back(said.key, said.words);
|
|
2914
|
+
}
|
|
2775
2915
|
return { registrations, modelsView };
|
|
2776
2916
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
export declare const SIGNUP_COOKIE = "so-signup";
|
|
3
|
+
/** A fresh Set-Cookie value for the page that shows the form. */
|
|
4
|
+
export declare function signupCookie(secure: string): string;
|
|
5
|
+
/** Whether this post came from the console's own first-account page. */
|
|
6
|
+
export declare function signupFromHere(request: IncomingMessage, allowedHost: (host: string | undefined) => boolean): boolean;
|
|
7
|
+
/** The typed code against the printed one, in constant time over fixed-length digests: any input (empty, too long,
|
|
8
|
+
* non-ASCII) is an ordinary wrong code, never a throw. */
|
|
9
|
+
export declare function setupCodeMatches(typed: string, printed: string): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The first-account form's same-site gate and setup-code comparison (Settings revamp S3, Codex round 1).
|
|
2
|
+
*
|
|
3
|
+
* The form is signed out, so it has no session CSRF token. Instead, the page that shows it sets a signed, HttpOnly,
|
|
4
|
+
* SameSite=Strict cookie scoped to /signup; a browser never sends it on a post from another site. A post must carry
|
|
5
|
+
* it, and its provenance must name this console: same-origin Fetch Metadata, or an Origin (or Referer) on an allowed
|
|
6
|
+
* host. Neither (`Origin: null`, nothing else) fails closed; the form's page is served `Referrer-Policy: same-origin`
|
|
7
|
+
* so its own post carries the real Origin. A refusal here never spends a setup-code try. */
|
|
8
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
export const SIGNUP_COOKIE = "so-signup";
|
|
10
|
+
const SECRET = randomBytes(32);
|
|
11
|
+
const NONCE = /^[A-Za-z0-9_-]{24}$/;
|
|
12
|
+
const macOf = (nonce) => createHmac("sha256", SECRET).update(`signup:${nonce}`).digest();
|
|
13
|
+
/** A fresh Set-Cookie value for the page that shows the form. */
|
|
14
|
+
export function signupCookie(secure) {
|
|
15
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
16
|
+
return `${SIGNUP_COOKIE}=${nonce}.${macOf(nonce).toString("base64url")}; HttpOnly; SameSite=Strict; Path=/signup${secure}`;
|
|
17
|
+
}
|
|
18
|
+
/** Whether this post came from the console's own first-account page. */
|
|
19
|
+
export function signupFromHere(request, allowedHost) {
|
|
20
|
+
const site = request.headers["sec-fetch-site"];
|
|
21
|
+
if (site !== undefined && site !== "same-origin" && site !== "none")
|
|
22
|
+
return false;
|
|
23
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
24
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
25
|
+
if (named !== null && !allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]))
|
|
26
|
+
return false;
|
|
27
|
+
if (site !== "same-origin" && named === null)
|
|
28
|
+
return false;
|
|
29
|
+
const held = new RegExp(`(?:^|;\\s*)${SIGNUP_COOKIE}=([^;.]*)\\.([^;]*)`).exec(request.headers.cookie ?? "");
|
|
30
|
+
if (held === null || !NONCE.test(held[1]))
|
|
31
|
+
return false;
|
|
32
|
+
const given = Buffer.from(held[2], "base64url"), wanted = macOf(held[1]);
|
|
33
|
+
return given.length === wanted.length && timingSafeEqual(given, wanted);
|
|
34
|
+
}
|
|
35
|
+
/** The typed code against the printed one, in constant time over fixed-length digests: any input (empty, too long,
|
|
36
|
+
* non-ASCII) is an ordinary wrong code, never a throw. */
|
|
37
|
+
export function setupCodeMatches(typed, printed) {
|
|
38
|
+
const digest = (text) => createHash("sha256").update(text, "utf8").digest();
|
|
39
|
+
return typed !== "" && timingSafeEqual(digest(typed), digest(printed));
|
|
40
|
+
}
|
package/dist/server/tasks.js
CHANGED
|
@@ -27,7 +27,8 @@ import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
|
|
|
27
27
|
import { CodingActionError } from '../coding-workspace.js';
|
|
28
28
|
import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
29
29
|
import { hasForbiddenControls, validateNote } from "../decision.js";
|
|
30
|
-
import { diagnoseTaskDispatch } from "../dispatch.js";
|
|
30
|
+
import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
|
|
31
|
+
import { withBuilderStep } from "../first-run.js";
|
|
31
32
|
import { evidencePack, evidencePackHtml } from "../evidence-pack.js";
|
|
32
33
|
import { readVerifiedArtifact, readVerifiedReport, writeEvidenceFile } from "../evidence.js";
|
|
33
34
|
import { computeGaps } from "../gaps.js";
|
|
@@ -72,7 +73,7 @@ import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReview
|
|
|
72
73
|
import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
|
|
73
74
|
import {} from "./session.js";
|
|
74
75
|
export function createTasksHandlers(runtime) {
|
|
75
|
-
const { store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
|
|
76
|
+
const { identities, store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
|
|
76
77
|
/** A row admitted the address but its path parameter does not parse: the console's unknown page. */
|
|
77
78
|
function noPage(who, response) {
|
|
78
79
|
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
@@ -175,7 +176,8 @@ export function createTasksHandlers(runtime) {
|
|
|
175
176
|
})),
|
|
176
177
|
cancelledBlockers: cancelled,
|
|
177
178
|
gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
|
|
178
|
-
|
|
179
|
+
// The builder step speaks for the same project as Chat's (firstRunBuilderRepo), as dispatch decides.
|
|
180
|
+
wizard: withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now)),
|
|
179
181
|
worker: (() => {
|
|
180
182
|
// The one fact the inbox must never hide (install review): with
|
|
181
183
|
// no worker answering, nothing here will ever build, and every
|
|
@@ -1020,10 +1022,16 @@ export function createTasksHandlers(runtime) {
|
|
|
1020
1022
|
}
|
|
1021
1023
|
let repo = effective;
|
|
1022
1024
|
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
1025
|
+
// What this filing is decided on: re-checked inside the write below, after every await (project-identity.ts).
|
|
1026
|
+
const decidedOn = identities.generation();
|
|
1023
1027
|
const rootMode = !unscopedMode && ceiling.roots.length > 0;
|
|
1024
1028
|
if (rootMode && effective !== "") {
|
|
1025
|
-
|
|
1026
|
-
|
|
1029
|
+
// Resolved off the console's thread (project-identity.ts), then proved against the ceiling as that identity.
|
|
1030
|
+
const resolved = await identities.resolveNow(effective);
|
|
1031
|
+
if (resolved === "no-answer")
|
|
1032
|
+
return newTaskScreen(ctx, 503, `${effective} didn't answer — its folder may be on a drive that's disconnected, or waiting on macOS; try again in a moment`, draft);
|
|
1033
|
+
const canonical = resolved !== null && (await authorizedProject(liveCeiling(), resolved)) ? resolved : null;
|
|
1034
|
+
if (canonical === null) {
|
|
1027
1035
|
return newTaskScreen(ctx, 403, `${effective} is outside what this server was configured to show`, draft);
|
|
1028
1036
|
}
|
|
1029
1037
|
repo = canonical;
|
|
@@ -1048,6 +1056,10 @@ export function createTasksHandlers(runtime) {
|
|
|
1048
1056
|
const after = (body.get("after") ?? "").trim();
|
|
1049
1057
|
let chainProblem = null;
|
|
1050
1058
|
const made = store.transact(() => {
|
|
1059
|
+
// A project folder's identity changed while this request waited (a root or link now leads elsewhere): what was
|
|
1060
|
+
// proved above may no longer hold, so nothing is filed on it.
|
|
1061
|
+
if (identities.generation() !== decidedOn || (rootMode && repo !== "" && !visible(repo)))
|
|
1062
|
+
return null;
|
|
1051
1063
|
const filed = fileTaskProposal(store, {
|
|
1052
1064
|
...(id === "" ? {} : { id }),
|
|
1053
1065
|
title,
|
|
@@ -1079,6 +1091,8 @@ export function createTasksHandlers(runtime) {
|
|
|
1079
1091
|
applyModeToNewFiling(store, filed.id, who.name, now);
|
|
1080
1092
|
return filed;
|
|
1081
1093
|
});
|
|
1094
|
+
if (made === null)
|
|
1095
|
+
return newTaskScreen(ctx, 409, "a project folder changed while this was being filed — check the project and send it again", draft);
|
|
1082
1096
|
if (!made.ok) {
|
|
1083
1097
|
return newTaskScreen(ctx, made.reason === "backlog-full" ? 429 : 400, made.message, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent });
|
|
1084
1098
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings kit's one script (docs/plans/settings-revamp.md §4), shipped as a page's functional script beside kit
|
|
3
|
+
* forms (settings-kit.ts). Without it every kit form works as today: the proof field shows inline and Save posts.
|
|
4
|
+
*
|
|
5
|
+
* With it, a kit form that asks for a proof:
|
|
6
|
+
* - moves its server-written proof field into the `<dialog>` inside the same form (the field never leaves the form);
|
|
7
|
+
* - opens the dialog on submit instead of posting, remembering the pressed button;
|
|
8
|
+
* - Confirm (its button, or Enter in the field) posts the form with that button (`requestSubmit(submitter)`, after the
|
|
9
|
+
* submit event that asked has finished), so its name=value and every hidden term go along unchanged; Cancel (or
|
|
10
|
+
* Escape) closes it and posts nothing, and focus returns to the button;
|
|
11
|
+
* - once a kit form posts, it is pending: further submits are dropped and its Save and Confirm are disabled (after the
|
|
12
|
+
* post has taken their values), until the page is shown again or the post was cancelled — never on a timer;
|
|
13
|
+
* - asks for nothing when the proof was left out (Passwordless) — there is no dialog then — and shows the
|
|
14
|
+
* identity-provider link when the sign-in swapped the field.
|
|
15
|
+
* Each section's Save bar names what changed. The script never reads or sets a field's value except to check that a
|
|
16
|
+
* required proof isn't empty; the server still proves everything.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SETTINGS_KIT_SCRIPT = "(function(){\nif(window.__soKit)return;window.__soKit=1;\nvar d=document;d.documentElement.classList.add(\"kit-js\");\nfunction label(el){var l=el.labels&&el.labels[0];if(!l&&el.closest)l=el.closest(\"fieldset\")&&el.closest(\"fieldset\").querySelector(\"legend\");return l?(l.textContent||\"\").replace(/\\s+/g,\" \").trim():(el.name||\"\");}\nfunction prep(f){if(f.__kit)return;f.__kit=1;\nvar bar=f.querySelector(\"[data-kit-state]\");if(bar&&bar.textContent===\"\")bar.textContent=bar.getAttribute(\"data-idle\")||\"\";\nif(!f.hasAttribute(\"data-kit-step-up\"))return;\nvar dl=f.querySelector(\"[data-kit-dialog]\"),p=f.querySelector(\":scope>[data-kit-proof]\");\nif(!dl||!p||typeof dl.showModal!==\"function\"){f.removeAttribute(\"data-kit-step-up\");return;}\ndl.querySelector(\"[data-kit-slot]\").appendChild(p);\np.querySelectorAll(\"[required]\").forEach(function(i){i.removeAttribute(\"required\");i.setAttribute(\"data-kit-required\",\"\");});\ndl.addEventListener(\"close\",function(){if(f.__kitSent)return;var back=f.__kitBy||f.querySelector(\".kit-save\");if(back&&back.focus)back.focus();});}\nfunction scan(root){if(root.querySelectorAll)root.querySelectorAll(\"form.kit-form\").forEach(prep);}\nscan(d);\nif(typeof MutationObserver!==\"undefined\")new MutationObserver(function(list){list.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches(\"form.kit-form\"))prep(n);scan(n);}});});}).observe(d.documentElement,{childList:true,subtree:true});\nfunction asks(dl){var slot=dl.querySelector(\"[data-kit-slot]\");return !!slot.querySelector(\"input:not([type=hidden]),a,.sso-step-up\");}\nfunction confirm(f){var dl=f.querySelector(\"[data-kit-dialog]\"),miss=null;\ndl.querySelectorAll(\"[data-kit-required]\").forEach(function(i){if(!miss&&i.value===\"\")miss=i;});\nif(miss){miss.setCustomValidity(\"Enter your password.\");miss.reportValidity();miss.focus();return;}\nvar by=f.__kitBy&&f.__kitBy.form===f?f.__kitBy:null;f.__kitSent=1;dl.close();\nsetTimeout(function(){f.__kitGo=1;try{by?f.requestSubmit(by):f.requestSubmit();}finally{f.__kitGo=0;}},0);}\nfunction buttons(f){return f.querySelectorAll(\".kit-save,[data-kit-confirm]\");}\nfunction release(f){f.__kitPending=0;f.__kitSent=0;buttons(f).forEach(function(b){b.disabled=false;});}\nfunction latch(f,e){f.__kitPending=1;setTimeout(function(){if(e.defaultPrevented){release(f);return;}\nbuttons(f).forEach(function(b){b.disabled=true;});},0);}\naddEventListener(\"pageshow\",function(){d.querySelectorAll(\"form.kit-form\").forEach(release);});\nd.addEventListener(\"submit\",function(e){var f=e.target;if(!f||!f.classList||!f.classList.contains(\"kit-form\"))return;prep(f);\nif(f.__kitPending){e.preventDefault();e.stopImmediatePropagation();return;}\nvar dl=f.hasAttribute(\"data-kit-step-up\")?f.querySelector(\"[data-kit-dialog]\"):null;\nif(f.__kitGo||!dl||!asks(dl)){latch(f,e);return;}\ne.preventDefault();e.stopImmediatePropagation();\nif(dl.open){confirm(f);return;}\nf.__kitBy=e.submitter||null;f.__kitSent=0;dl.showModal();\nvar first=dl.querySelector(\"[data-kit-slot] input:not([type=hidden]),[data-kit-slot] a\")||dl.querySelector(\"[data-kit-confirm]\");if(first)first.focus();},true);\nd.addEventListener(\"click\",function(e){var b=e.target&&e.target.closest?e.target.closest(\"[data-kit-confirm],[data-kit-cancel]\"):null;if(!b)return;\nvar dl=b.closest(\"[data-kit-dialog]\"),f=dl&&dl.closest(\"form\");if(!f)return;e.preventDefault();\nif(b.hasAttribute(\"data-kit-cancel\")){dl.close();return;}confirm(f);});\nd.addEventListener(\"input\",function(e){var t=e.target;if(t&&t.setCustomValidity&&t.hasAttribute&&t.hasAttribute(\"data-kit-required\"))t.setCustomValidity(\"\");},true);\nfunction changed(e){var t=e.target,f=t&&t.form;if(!f||!f.classList.contains(\"kit-form\")||t.closest(\"[data-kit-dialog]\")||t.type===\"hidden\")return;prep(f);\nvar bar=f.querySelector(\"[data-kit-save-bar]\"),state=f.querySelector(\"[data-kit-state]\");if(!bar||!state)return;\nf.__kitChanged=f.__kitChanged||[];var name=label(t);if(name&&f.__kitChanged.indexOf(name)<0)f.__kitChanged.push(name);\nbar.setAttribute(\"data-changed\",\"\");state.textContent=\"Changed: \"+f.__kitChanged.join(\", \");}\nd.addEventListener(\"input\",changed);d.addEventListener(\"change\",changed);\n})();";
|
|
19
|
+
/** The pages that wear the settings frame: one column, no Crew sheet, a breadcrumb under Settings. Here (not in
|
|
20
|
+
* settings-kit.ts) so the browser bundle reads it without the server's form contracts. */
|
|
21
|
+
export declare function isSettingsFrame(pathname: string): boolean;
|