toolroll 0.9.49 → 0.9.51
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 +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.js +6 -11
- package/dist/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +8 -23
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +9 -2
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +434 -23789
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +693 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +200 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.js +3 -0
- package/dist/sign-in-guard.d.ts +41 -16
- package/dist/sign-in-guard.js +147 -24
- package/dist/source-key.d.ts +2 -0
- package/dist/source-key.js +49 -0
- package/dist/team-http.d.ts +8 -1
- package/dist/team-http.js +9 -2
- package/dist/teams.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2388 @@
|
|
|
1
|
+
import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
2
|
+
import { galleryTemplateOf } from "../flow-gallery.js";
|
|
3
|
+
import { finishGoogleConsent, GOOGLE_CALLBACK } from "../google-mail.js";
|
|
4
|
+
import { kitInstalled, kitOf } from "../kits.js";
|
|
5
|
+
import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClickOf } from "../mcp-connect.js";
|
|
6
|
+
import { learningView } from "../project-learning.js";
|
|
7
|
+
import { skillTestResult } from "../project-skills.js";
|
|
8
|
+
import { discoverTools, projectToolsOf } from "../project-tools.js";
|
|
9
|
+
import { publishingOf } from '../pull-request-flow.js';
|
|
10
|
+
import { skillTestFeedbackHtml } from "../skills-ui.js";
|
|
11
|
+
import { nameOf } from "../teammate-admin.js";
|
|
12
|
+
import { grantTool } from "../teammate-tools.js";
|
|
13
|
+
import { learningHtml } from "../workspace-ui.js";
|
|
14
|
+
import { adapterPolicy } from "./route-policy.js";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import {} from "node:http";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { INSTALLATION_SCOPE } from "../agentconfig.js";
|
|
19
|
+
import { rulesSummary } from "../approval-rules-ui.js";
|
|
20
|
+
import { installMethod } from "../install-method.js";
|
|
21
|
+
import { logEvent } from "../log.js";
|
|
22
|
+
import { modeTermsFromJson } from "../modes.js";
|
|
23
|
+
import { envValue } from "../names.js";
|
|
24
|
+
import { REMOTE_MESSAGES } from "../operate-remote.js";
|
|
25
|
+
import { canonicalProject, projectName } from "../project.js";
|
|
26
|
+
import { ALL_CREDENTIAL_ENV } from "../provider.js";
|
|
27
|
+
import { updateChecksOff } from "../releases.js";
|
|
28
|
+
import { authenticateAccount } from "../scope.js";
|
|
29
|
+
import { budgetLabel, monthOf } from "../spend.js";
|
|
30
|
+
import { PACKAGE_VERSION } from "../version.js";
|
|
31
|
+
import { escape, HANDOFF_STYLE, redirect, refuse, respond, safeReturn, screen, signInSpent, startedHere } from "./shared.js";
|
|
32
|
+
/** settings handlers, moved without changing their route bodies. */
|
|
33
|
+
import { DEFAULT_ACCENT, normalHex } from "../accent-colors.js";
|
|
34
|
+
import { resolvePhaseAgent } from "../agentconfig.js";
|
|
35
|
+
import { parseProtectedPaths } from "../approval-policy.js";
|
|
36
|
+
import { approvalRulesHtml } from "../approval-rules-ui.js";
|
|
37
|
+
import { backupHtml } from "../backup-ui.js";
|
|
38
|
+
import { BACKUP_EVERY_HOURS, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder, MAX_KEEP } from "../backup.js";
|
|
39
|
+
import { projectBatchChecks, setProjectBatchChecks } from '../batch-policy.js';
|
|
40
|
+
import { ALL_PROJECTS as CHAT_APPROVAL_ALL, chatApprovalWords, setChatApproval } from "../chat-approval.js";
|
|
41
|
+
import { ChatState } from "../chat-delivery-state.js";
|
|
42
|
+
import { checkSettingsHtml } from '../check-levels-ui.js';
|
|
43
|
+
import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from '../check-levels.js';
|
|
44
|
+
import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "../checkout-cleanup.js";
|
|
45
|
+
import { approveSetup, previewSetup } from "../control-setup.js";
|
|
46
|
+
import { connectionHtml, controlSetupHtml, hiddenFields, setupPreviewHtml } from "../control-ui.js";
|
|
47
|
+
import { hasDisguisedText } from '../decision.js';
|
|
48
|
+
import { checkDiscordCredentials, clearDiscordCredentials, DiscordError, loadDiscordCredentials, saveDiscordCredentials } from "../discord-api.js";
|
|
49
|
+
import { discordSettingsHtml } from "../discord-settings.js";
|
|
50
|
+
import { dataExportHtml } from "../export-ui.js";
|
|
51
|
+
import { buildExport, exportZip } from "../export.js";
|
|
52
|
+
import { firstResultWords } from "../first-run.js";
|
|
53
|
+
import { readEmailSettings, saveEmailSettings, sendingAccount, sendThroughServer } from "../flow-actions.js";
|
|
54
|
+
import { galleryHtml } from "../flow-gallery-ui.js";
|
|
55
|
+
import { startersHtml } from "../flow-starters-ui.js";
|
|
56
|
+
import { starterOf, startersFor, switchOnStarter } from "../flow-starters.js";
|
|
57
|
+
import { disconnectGoogle, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "../google-mail.js";
|
|
58
|
+
import { integrationsHtml } from "../integrations-ui.js";
|
|
59
|
+
import { clearProviderKey, keyStatus, plausibleKey, PROVIDER_KEY_ENV, readAuthMode, readProviderKey, saveProviderKey, setAuthMode, SUBSCRIPTION_CAPABLE, verdictWords, verifyProviderKey } from "../keys.js";
|
|
60
|
+
import { decisionsHtml, knowledgeHtml, MEMORY_INTRO, memorySearchHtml, proposalsHtml } from "../knowledge-ui.js";
|
|
61
|
+
import { aboutYouOf, checkAboutYou, saveAboutYou } from "../lead-about.js";
|
|
62
|
+
import { cancelCommitment, conditionWords, openCommitments } from '../lead-commitments.js';
|
|
63
|
+
import { checkLeadIdentity, leadIdentityOf } from "../lead-identity.js";
|
|
64
|
+
import { mailboxAccess, readThroughImap } from "../mailbox.js";
|
|
65
|
+
import { startConnect } from "../mcp-connect.js";
|
|
66
|
+
import { decideProposal, listProposals, memoryStatus } from "../memory-pass.js";
|
|
67
|
+
import { checkModels, isNewModel, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "../model-catalog.js";
|
|
68
|
+
import { modelsHtml, modelsScript } from "../models-ui.js";
|
|
69
|
+
import { MODE_MAX_DAYS, modeDigestOf, modeTermsJson, modeWords, presetTerms } from "../modes.js";
|
|
70
|
+
import { monitoringChange, readMonitoring, saveMonitoring } from "../monitoring-settings.js";
|
|
71
|
+
import { monitoringHtml, signingSecretHtml } from "../monitoring-ui.js";
|
|
72
|
+
import { openRouterPickerScript } from "../openrouter-models.js";
|
|
73
|
+
import { policyHtml } from "../policy-ui.js";
|
|
74
|
+
import { checkPolicy, parseList, policyParts } from "../policy.js";
|
|
75
|
+
import { parseProjectConcurrency, saveProjectConcurrency } from "../project-concurrency.js";
|
|
76
|
+
import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
|
|
77
|
+
import { deleteProject, holdingsWords } from "../project-delete.js";
|
|
78
|
+
import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView } from "../project-knowledge.js";
|
|
79
|
+
import { changeLearning } from "../project-learning.js";
|
|
80
|
+
import { listDecisions, recordDecision, retireDecision, searchMemory } from "../project-memory.js";
|
|
81
|
+
import { changeSkills, githubSkill, importSkill, reviseSkillTest, skillsView, testSkill } from "../project-skills.js";
|
|
82
|
+
import { addToolTo, catalogTool, localAppOf, removeToolFrom, setToolSecret, splitCommandLine, testToolOf } from "../project-tools.js";
|
|
83
|
+
import { liftAuthPause } from "../provider-auth.js";
|
|
84
|
+
import { isProviderId, PROVIDER_IDS, validateSpec, validModelId } from "../provider.js";
|
|
85
|
+
import { checkPublishing, MERGE_METHODS, saveMergeSettings, savePublishing } from '../pull-request-flow.js';
|
|
86
|
+
import { pullRequestSettingsHtml } from '../pull-request-ui.js';
|
|
87
|
+
import { isQualityMode } from "../quality.js";
|
|
88
|
+
import { setUpdateChecks } from "../releases.js";
|
|
89
|
+
import { removeRepos, updateRepos } from "../repos.js";
|
|
90
|
+
import { repositoryContextHtml } from '../repository-context-ui.js';
|
|
91
|
+
import { repositoryContext, repositoryContextRead } from '../repository-context.js';
|
|
92
|
+
import { parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, setLimitOverride } from "../request-budget.js";
|
|
93
|
+
import { retentionHtml } from "../retention-ui.js";
|
|
94
|
+
import { lastSweepAt, parsePeriod, periodChoices, RETENTION_KINDS, retentionPlan } from "../retention.js";
|
|
95
|
+
import { addProjectInstructions, ASSISTANTS, detectPreparation, modelChoices, previewProjectInstructions } from "../setup-guide.js";
|
|
96
|
+
import { skillsHtml, skillsScript } from "../skills-ui.js";
|
|
97
|
+
import { checkSlackCredentials, clearSlackCredentials, loadSlackCredentials, saveSlackCredentials, SLACK_MANIFEST, SlackError } from "../slack-api.js";
|
|
98
|
+
import { slackSettingsHtml } from "../slack-settings.js";
|
|
99
|
+
import { SlackState } from "../slack-state.js";
|
|
100
|
+
import { spendCsv, spendHtml } from "../spend-ui.js";
|
|
101
|
+
import { budgetStates, monthNamed, spendItems, usd as spendUsd, teammateNames as teammateNamesOf } from "../spend.js";
|
|
102
|
+
import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
|
|
103
|
+
import { ssoSettingsHtml } from "../sso-ui.js";
|
|
104
|
+
import { lastSweep, saveSweep, storageSweepOff } from "../storage-sweep.js";
|
|
105
|
+
import { storageHtml } from "../storage-ui.js";
|
|
106
|
+
import { bytesWords, parseCleanup } from "../storage.js";
|
|
107
|
+
import { isDigestTime, RESULT_SCREENSHOTS } from "../store.js";
|
|
108
|
+
import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
|
|
109
|
+
import { teamsSettingsHtml } from "../teams-settings.js";
|
|
110
|
+
import { telegramSettingsHtml } from "../telegram-settings.js";
|
|
111
|
+
import { hashPairingCode, loadBotToken, mintPairingCode, PAIRING_TTL_MS, redactToken, saveBotToken } from "../telegram.js";
|
|
112
|
+
import { newerThan, updatesHtml, updatesScript, updateStepsHtml } from "../toolroll-update-ui.js";
|
|
113
|
+
import { abandonRuntimeUpdate, currentRuntime, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, releaseStalledUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal } from "../toolroll-update.js";
|
|
114
|
+
import { TOOLS_PROJECT_SCRIPT, toolsHtml, toolsProjectPicker } from "../tools-ui.js";
|
|
115
|
+
import { effectivePrimary, isMessagingChannel, savePrimary } from "../webhooks.js";
|
|
116
|
+
import { chatReturnWithSaid, goOutside, latestReleaseFor, leadSettingsHtml, notificationProjects, personChip, ROLE_TITLES, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./shared.js";
|
|
117
|
+
export function createSettingsHandlers(runtime) {
|
|
118
|
+
const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, deletedRepos, recordSignIn, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
|
|
119
|
+
async function get(ctx) {
|
|
120
|
+
const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
|
|
121
|
+
// v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
|
|
122
|
+
if (url.pathname === "/spend") {
|
|
123
|
+
if (!store.isInstanceOperator(who.name))
|
|
124
|
+
return refuse(response, who, 403, "An instance operator sees spend.", "/");
|
|
125
|
+
const current = monthOf(now);
|
|
126
|
+
const asked = url.searchParams.get("month");
|
|
127
|
+
const month = asked === null ? current : monthNamed(asked);
|
|
128
|
+
if (month === null)
|
|
129
|
+
return refuse(response, who, 400, "Choose a month like 2026-09.", "/spend");
|
|
130
|
+
const items = spendItems(store.handle, month.from, month.to);
|
|
131
|
+
const teammates = store.teammates([...new Set([...managedRepos(), ...store.knownRepos()])]);
|
|
132
|
+
const teammateNames = teammateNamesOf(store.handle);
|
|
133
|
+
if (url.searchParams.get("format") === "csv") {
|
|
134
|
+
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" });
|
|
135
|
+
return void response.end(spendCsv(items, teammateNames));
|
|
136
|
+
}
|
|
137
|
+
const shift = (by) => { const at = new Date(`${month.name}-01T00:00:00.000Z`); at.setUTCMonth(at.getUTCMonth() + by); return at.toISOString().slice(0, 7); };
|
|
138
|
+
const projects = consoleProjects();
|
|
139
|
+
const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
|
|
140
|
+
const view = {
|
|
141
|
+
month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1),
|
|
142
|
+
items, budgets: budgetStates(store.budgets(), items), teammateNames, csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
143
|
+
targets: [
|
|
144
|
+
{ value: "installation:*", label: "Everything", group: "Everything" },
|
|
145
|
+
...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
|
|
146
|
+
...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
|
|
147
|
+
...teammates.map(one => ({ value: `teammate:${one.id}`, label: `${teammateNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Teammates" })),
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
return sendScreen(response, 200, screen("Spend", spendHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "spend") }));
|
|
151
|
+
}
|
|
152
|
+
if (url.pathname === "/mode") {
|
|
153
|
+
if (project === null) {
|
|
154
|
+
return refuse(response, who, 409, "open a project first — a mode is signed per repository");
|
|
155
|
+
}
|
|
156
|
+
const live = store.activeMode(project, now);
|
|
157
|
+
const liveTerms = live === null ? null : modeTermsFromJson(live.termsJson);
|
|
158
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
159
|
+
const hasGrant = store.hasMergeCapableGrant(project, now);
|
|
160
|
+
const current = live === null || liveTerms === null
|
|
161
|
+
? `<div class="card"><h2>Locked</h2><p class="meta">No mode is signed — every action asks for your password. That is the default.</p></div>`
|
|
162
|
+
: [
|
|
163
|
+
`<div class="card">`,
|
|
164
|
+
`<h2 style="margin-top:0">${escape(live.name)} <span class="meta">signed by ${personChip(live.signedBy)}</span></h2>`,
|
|
165
|
+
modeWords(liveTerms)
|
|
166
|
+
.map(words => `<p class="row">${escape(words)}</p>`)
|
|
167
|
+
.join("\n"),
|
|
168
|
+
who.role === "approver"
|
|
169
|
+
? `<form method="post" action="/mode/revoke" class="row">` +
|
|
170
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}">` +
|
|
171
|
+
`<button type="submit">End this mode now</button>` +
|
|
172
|
+
`<span class="meta">one click — every act it covered falls back to its own ceremony</span></form>`
|
|
173
|
+
: "",
|
|
174
|
+
`</div>`,
|
|
175
|
+
].join("\n");
|
|
176
|
+
const signForm = who.role !== "approver"
|
|
177
|
+
? ""
|
|
178
|
+
: [
|
|
179
|
+
`<div class="card">`,
|
|
180
|
+
`<h2 style="margin-top:0">${live === null ? "sign a mode" : "replace it — a renewal is a new signature"}</h2>`,
|
|
181
|
+
`<form method="post" action="/mode/confirm">`,
|
|
182
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}">`,
|
|
183
|
+
`<label>Preset<select name="name"><option value="standard">standard — approvals stay with me</option><option value="hands-off">hands-off — your filings auto-approve, full permissions</option></select></label>`,
|
|
184
|
+
`<label>Days <span class="meta">(1\u2013${MODE_MAX_DAYS})</span><input type="number" name="days" value="1" min="1" max="${MODE_MAX_DAYS}"></label>`,
|
|
185
|
+
`<label>Merges<select name="publication">` +
|
|
186
|
+
`<option value="notify">wait for me — even under a merge grant</option>` +
|
|
187
|
+
(hasGrant ? `<option value="automerge">merge themselves when CI is green on the exact commit</option>` : "") +
|
|
188
|
+
`</select></label>` +
|
|
189
|
+
(hasGrant ? "" : `<span class="meta">self-merging needs a merge-capable publication grant first</span>`),
|
|
190
|
+
`<label>My filings<select name="auto-approve">` +
|
|
191
|
+
`<option value="">the preset's default (standard: wait for approval; hands-off: auto-approve)</option>` +
|
|
192
|
+
`<option value="1">approve the moment I file them</option>` +
|
|
193
|
+
`<option value="0">wait for their own approval</option>` +
|
|
194
|
+
`</select></label>`,
|
|
195
|
+
`<label>Planner approval<select name="plan-auto">` +
|
|
196
|
+
`<option value="0">wait for my approval</option>` +
|
|
197
|
+
`<option value="1">auto-approve plans that preserve my filed contract</option>` +
|
|
198
|
+
`</select><span class="meta">Requires automatic filing approval. File the goal, paths, and acceptance criteria upfront. Changed scope and unanswered questions still pause.</span></label>`,
|
|
199
|
+
`<label>From my chat app<select name="chat-approve">` +
|
|
200
|
+
`<option value="">approve plans and merges here, with my password (the default)</option>` +
|
|
201
|
+
`<option value="1">approve plans and merge ready pull requests from my paired chat, two taps each</option>` +
|
|
202
|
+
`</select><span class="meta">Plans that widen permissions, exceed the attempt cap or touch protected paths still open Toolroll.</span></label>`,
|
|
203
|
+
`<label>If a subscription runs out<select name="allow-paid-fallback">` +
|
|
204
|
+
`<option value="">never switch to a paid API key on its own (the default, every preset)</option>` +
|
|
205
|
+
`<option value="1">allow the approved fallback — spend moves to that account</option>` +
|
|
206
|
+
`</select></label>`,
|
|
207
|
+
`<button type="submit">Read the full terms</button>`,
|
|
208
|
+
`</form>`,
|
|
209
|
+
`</div>`,
|
|
210
|
+
].join("\n");
|
|
211
|
+
return sendScreen(response, 200, screen("mode", [`<h1>Operating mode</h1><p>Authorize this project's automatic approvals once, for up to ${MODE_MAX_DAYS} days. Select the touchpoints below; the signature covers the exact choices. Existing modes keep their original terms.</p><p class="meta">Scope changes, agent questions, and requested revisions still require your attention. Automatic merging also needs a publication grant.</p>`, current, signForm].join("\n"), { chrome: chromeFor(project, "mode") }));
|
|
212
|
+
}
|
|
213
|
+
if (url.pathname === "/control" || url.pathname === "/control/connection") {
|
|
214
|
+
if (who.role !== "approver")
|
|
215
|
+
return refuse(response, who, 403, "Project setup requires an approver.");
|
|
216
|
+
if (project === null)
|
|
217
|
+
return redirect(response, "/projects");
|
|
218
|
+
const saved = store.phaseConfig(project, "build");
|
|
219
|
+
const asked = url.searchParams.get("provider") ?? saved?.provider ?? "claude";
|
|
220
|
+
if (!isProviderId(asked))
|
|
221
|
+
return refuse(response, who, 400, "Choose a known provider.", "/control");
|
|
222
|
+
const task = url.searchParams.get("task") ?? "";
|
|
223
|
+
const connection = await connectionCheck(asked, url.searchParams.get("check-connection") === "1");
|
|
224
|
+
if (url.pathname === "/control/connection") {
|
|
225
|
+
const command = asked === "claude" ? "claude auth login" : asked === "codex" ? "codex login" : asked === "gemini" ? "gemini" : null;
|
|
226
|
+
return sendScreen(response, 200, screen(`${ASSISTANTS[asked].name} connection`, `<h1>${escape(ASSISTANTS[asked].name)} ${connection.state === "connected" ? "is connected" : "connection"}</h1>` +
|
|
227
|
+
connectionHtml(asked, connection, task) + (command === null ? "" : `<p>On the computer running Toolroll, sign in with:</p><pre class="recap">${escape(command)}</pre>`) +
|
|
228
|
+
`<p><a href="/settings#providers">Manage API keys and authentication mode</a></p>` + hiddenFields({ "resume-task": task }), { chrome: chromeFor(project, "settings") }));
|
|
229
|
+
}
|
|
230
|
+
const setup = store.liveWorktreeSetup(project);
|
|
231
|
+
const preparation = detectPreparation(project);
|
|
232
|
+
// The saved live catalog when it has models for this provider; the
|
|
233
|
+
// built-in short list otherwise.
|
|
234
|
+
const live = asked === "openrouter" ? [] : modelOptions(store, asked, clock(), providerHome).map(({ value, label }) => ({ value, label }));
|
|
235
|
+
const configured = saved?.provider === asked ? saved.model : null;
|
|
236
|
+
const models = live.length === 0 ? modelChoices(asked, configured, providerHome)
|
|
237
|
+
: configured && !live.some(one => one.value === configured) ? [{ value: configured, label: `${configured} — current choice` }, ...live] : live;
|
|
238
|
+
const instructions = previewProjectInstructions(project);
|
|
239
|
+
const catalog = asked === "openrouter" ? await modelCatalog(readProviderKey("openrouter", providerHome), url.searchParams.get("refresh-models") === "1") : null;
|
|
240
|
+
const inputs = { provider: asked, model: saved?.provider === asked ? saved.model ?? "" : models[0]?.value ?? "", command: setup?.command ?? (saved === null ? preparation?.command ?? "" : ""), seconds: String((setup?.timeoutMs ?? 300_000) / 1000) };
|
|
241
|
+
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,
|
|
242
|
+
instructions: instructions.ok ? { installed: instructions.installed } : { installed: false, message: instructions.message } }), { chrome: chromeFor(project, "settings"), ...(catalog === null ? {} : { functional: { script: openRouterPickerScript() } }) }));
|
|
243
|
+
}
|
|
244
|
+
if (url.pathname === "/settings/models") {
|
|
245
|
+
const now = clock();
|
|
246
|
+
const watch = watchState(store);
|
|
247
|
+
// Opening the page refreshes a stale snapshot; the lists are public and
|
|
248
|
+
// the check sends nothing about the person or their projects.
|
|
249
|
+
if (who.role === "approver" && (watch.checkedAt === null || now.getTime() - Date.parse(watch.checkedAt) > 3_600_000)) {
|
|
250
|
+
await checkModels(store, now, modelSeams).catch(() => undefined);
|
|
251
|
+
}
|
|
252
|
+
const view = modelsView(who.role === "approver", who.via === "cookie" ? who.session.csrf : "", now, url.searchParams.get("said"), url.searchParams.get("problem"));
|
|
253
|
+
return sendScreen(response, 200, screen("Models", `<p><a href="/settings">Settings</a></p><h1>Models</h1>${modelsHtml(view)}`, { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
|
|
254
|
+
}
|
|
255
|
+
if (url.pathname === "/settings/skills") {
|
|
256
|
+
const projects = consoleProjects();
|
|
257
|
+
const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
|
|
258
|
+
if (chosen && !projects.includes(chosen))
|
|
259
|
+
return refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
260
|
+
const selector = projects.length > 1 ? `<form class="skills" method="get"><label>Project<select name="repo">${projects.map(p => `<option value="${escape(p)}"${p === chosen ? ' selected' : ''}>${escape(p.split('/').at(-1) ?? p)}</option>`).join('')}</select></label><button>Show project</button></form>` : '';
|
|
261
|
+
let content = '<p>Add a project to manage its skills.</p>';
|
|
262
|
+
if (chosen) {
|
|
263
|
+
try {
|
|
264
|
+
const agent = resolvePhaseAgent(store, 'build', chosen, {});
|
|
265
|
+
content = skillsHtml(skillsView(store, chosen, who.name), who.via === 'cookie' ? who.session.csrf : '', who.role === 'approver', { focus: url.searchParams.get('skill') ?? '', agent: agent.ok ? `Report agent: ${agent.spec.provider} · ${agent.spec.model ?? 'default'}. Change it on the test task before approval` : 'Choose a report agent on the test task before approval' });
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
content = `<p class="problem" role="alert">${escape(error instanceof Error ? error.message : 'Skills are unavailable. Reload to retry.')}</p>`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return sendScreen(response, 200, screen('Skills', `<p><a href="/settings">Settings</a></p><h1>Skills</h1>${url.searchParams.get('saved') === '1' ? '<p role="status">Saved.</p>' : ''}${selector}${content}`, { chrome: chromeFor(chosen || project, 'settings'), functional: { script: skillsScript() } }));
|
|
272
|
+
}
|
|
273
|
+
if (url.pathname === "/settings/tools") {
|
|
274
|
+
const projects = consoleProjects();
|
|
275
|
+
const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
|
|
276
|
+
if (chosen && !projects.includes(chosen))
|
|
277
|
+
return refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
278
|
+
const kit = kitOf(url.searchParams.get("kit") ?? "")?.id ?? null, wanted = (oneClickOf(url.searchParams.get("connect") ?? "") ?? localConnectOf(url.searchParams.get("connect") ?? ""))?.id ?? null;
|
|
279
|
+
// Choosing a project opens it at once (keeping a kit's Connect), so every form below acts on the project shown.
|
|
280
|
+
const selector = toolsProjectPicker(projects, chosen, { ...(kit === null ? {} : { kit }), ...(wanted === null ? {} : { connect: wanted }) });
|
|
281
|
+
let content = "<p>Add a project to give its builds tools.</p>";
|
|
282
|
+
if (chosen) {
|
|
283
|
+
try {
|
|
284
|
+
const approver = who.role === "approver" && who.via === "cookie";
|
|
285
|
+
const view = toolsViewOf(chosen, approver ? await codexServers(chosen) : null, kit, wanted);
|
|
286
|
+
if (approver)
|
|
287
|
+
view.others = projects.filter(one => one !== chosen).map(one => ({ repo: one, project: projectName(one), open: connectionsOf(store, one).filter(c => c.state === "open").map(c => c.id) }));
|
|
288
|
+
content = toolsHtml(view, who.via === "cookie" ? who.session.csrf : "", approver, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
content = '<p class="problem" role="alert">Tools are unavailable. Reload to retry.</p>';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return sendScreen(response, 200, screen("Tools", `<p><a href="/settings">Settings</a></p><h1>Tools</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings"), functional: { script: SETTINGS_AUTOSAVE_SCRIPT + TOOLS_PROJECT_SCRIPT } }));
|
|
295
|
+
}
|
|
296
|
+
// Settings → Project: what Toolroll holds for a project; an instance operator deletes it here.
|
|
297
|
+
if (url.pathname === "/settings/project") {
|
|
298
|
+
const projects = consoleProjects();
|
|
299
|
+
const asked = url.searchParams.get("repo");
|
|
300
|
+
const chosen = asked ?? (project != null && projects.includes(project) ? project : projects[0] ?? "");
|
|
301
|
+
if (chosen && !projects.includes(chosen))
|
|
302
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
303
|
+
const selector = projects.length > 1 ? `<form class="approval-project" method="get" action="/settings/project"><label>Project<select name="repo">${projects.map(p => `<option value="${escape(p)}"${p === chosen ? " selected" : ""}>${escape(projectName(p))}</option>`).join("")}</select></label><button>Show</button></form>` : "";
|
|
304
|
+
const notice = { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") };
|
|
305
|
+
const content = chosen === "" ? `${notice.said ? `<p role="status">${escape(notice.said)}</p>` : ""}<p>No projects yet.</p>` : projectSettingsHtml(projectViewOf(chosen, who), who.via === "cookie" ? who.session.csrf : "", notice);
|
|
306
|
+
return sendScreen(response, 200, screen("Project", `<p><a href="/settings">Settings</a></p><h1>Project</h1>${selector}${content}`, { chrome: chromeFor(chosen || null, "settings") }));
|
|
307
|
+
}
|
|
308
|
+
// v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
|
|
309
|
+
if (url.pathname === "/settings/approval") {
|
|
310
|
+
const projects = consoleProjects();
|
|
311
|
+
const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
|
|
312
|
+
if (chosen && !projects.includes(chosen))
|
|
313
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
|
|
314
|
+
const selector = projects.length > 1 ? `<form class="approval-project" method="get" action="/settings/approval"><label>Project<select name="repo">${projects.map(p => `<option value="${escape(p)}"${p === chosen ? " selected" : ""}>${escape(projectName(p))}</option>`).join("")}</select></label><button>Show</button></form>` : "";
|
|
315
|
+
const approvers = chosen === "" ? 0 : store.listApprovers().filter(one => { const account = store.accountOf(one.name); return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(one.name, chosen); }).length;
|
|
316
|
+
const content = chosen === "" ? "<p>Add a project to set its approval rules.</p>" : approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, who.via === "cookie" ? who.session.csrf : "", { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
317
|
+
return sendScreen(response, 200, screen("Approval rules", `<p><a href="/settings">Settings</a></p><h1>Approval rules</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
318
|
+
}
|
|
319
|
+
// Sprint 8: Settings → Policy, the organisation policy and its history. Anyone signed in reads it; an instance operator changes it.
|
|
320
|
+
if (url.pathname === "/settings/policy") {
|
|
321
|
+
const repos = consoleProjects();
|
|
322
|
+
const toolNames = [...new Set(repos.flatMap(repo => store.projectTools(repo).map(one => one.name)))].sort();
|
|
323
|
+
const view = { policy: store.orgPolicy(), history: store.policyHistory(50), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), toolNames };
|
|
324
|
+
return sendScreen(response, 200, screen("Policy", `<p><a href="/settings">Settings</a></p><h1>Policy</h1>${policyHtml(view, who.via === "cookie" ? who.session.csrf : "", { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
325
|
+
}
|
|
326
|
+
// Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
|
|
327
|
+
// "Do this every time…" asked about.
|
|
328
|
+
if (url.pathname === "/settings/flows") {
|
|
329
|
+
const projects = consoleProjects();
|
|
330
|
+
const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
|
|
331
|
+
if (chosen && !projects.includes(chosen))
|
|
332
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings");
|
|
333
|
+
const content = chosen === "" ? "<p>Add a project to switch on starter flows.</p>" : startersHtml({
|
|
334
|
+
repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
335
|
+
canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
|
|
336
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
|
|
337
|
+
}) +
|
|
338
|
+
// Every template, beside the starters: Use this opens its page for this project.
|
|
339
|
+
`<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo(), connections: connectionsOf(store, chosen) })}`;
|
|
340
|
+
return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
341
|
+
}
|
|
342
|
+
// Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
|
|
343
|
+
if (url.pathname === "/settings/integrations") {
|
|
344
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
345
|
+
return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
|
|
346
|
+
const list = integrations.list();
|
|
347
|
+
return sendScreen(response, 200, screen("Integrations", `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking })}`, { chrome: chromeFor(project, "settings") }));
|
|
348
|
+
}
|
|
349
|
+
// v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
|
|
350
|
+
if (url.pathname === "/settings/monitoring") {
|
|
351
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
352
|
+
return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
|
|
353
|
+
const view = { settings: readMonitoring(options.configDir), status: store.monitoringStatus(), head: store.ledgerHeadId(), origin: consoleOrigin(request.headers.host) };
|
|
354
|
+
return sendScreen(response, 200, screen("Monitoring", `<p><a href="/settings">Settings</a></p><h1>Monitoring</h1>${monitoringHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
355
|
+
}
|
|
356
|
+
// Settings → Updates: the installed version, three ways to update, the update's steps live, then What's new. An instance operator's page.
|
|
357
|
+
if (url.pathname === "/settings/updates") {
|
|
358
|
+
const databaseFile = store.databaseFile();
|
|
359
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
360
|
+
return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
|
|
361
|
+
// A record it cannot release is shown as it is; the page never fails over it.
|
|
362
|
+
try {
|
|
363
|
+
releaseStalledUpdate(dirname(databaseFile), clock());
|
|
364
|
+
}
|
|
365
|
+
catch { /* shown as last saved */ }
|
|
366
|
+
const status = runtimeUpdateStatus(dirname(databaseFile));
|
|
367
|
+
if (url.searchParams.get("fragment") === "steps")
|
|
368
|
+
return respond(response, 200, "text/html; charset=utf-8", status.journal ? updateStepsHtml(status.journal, status.running) : `<div id="update-live" data-done="1"></div>`);
|
|
369
|
+
const active = status.journal !== null && !runtimeUpdateTerminal(status.journal.phase);
|
|
370
|
+
const current = options.updates?.current ?? PACKAGE_VERSION;
|
|
371
|
+
// While update checks are off the page asks npm nothing until Check now.
|
|
372
|
+
const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
|
|
373
|
+
const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
|
|
374
|
+
return sendScreen(response, 200, screen("Updates", `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
|
|
375
|
+
}
|
|
376
|
+
// v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
|
|
377
|
+
if (url.pathname === "/settings/retention") {
|
|
378
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
379
|
+
return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
|
|
380
|
+
const view = { periods: store.retentionPeriods(), chosen: Object.keys(store.retentionChosen()), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
|
|
381
|
+
return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
382
|
+
}
|
|
383
|
+
// Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
|
|
384
|
+
// finished task's clean checkout goes by itself. An instance operator's page.
|
|
385
|
+
if (url.pathname === "/settings/storage") {
|
|
386
|
+
const databaseFile = store.databaseFile();
|
|
387
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
388
|
+
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
389
|
+
const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
|
|
390
|
+
const sweep = { last: lastSweep(store), off: storageSweepOff() };
|
|
391
|
+
return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf, sweep }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
392
|
+
}
|
|
393
|
+
// Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
|
|
394
|
+
// exactly what turning on would do, or what to fix first.
|
|
395
|
+
if (url.pathname === "/settings/pull-requests") {
|
|
396
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
397
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
398
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
399
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
400
|
+
const publishing = publishingOf(store, repo);
|
|
401
|
+
const canChange = who.role === "approver" && !store.isDemo();
|
|
402
|
+
const check = publishing.on || !canChange ? null : await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
|
|
403
|
+
const html = pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
|
|
404
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
405
|
+
return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
406
|
+
}
|
|
407
|
+
// Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
|
|
408
|
+
if (url.pathname === "/settings/checks") {
|
|
409
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
410
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
411
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
412
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
413
|
+
const quick = liveQuickCommand(store, repo);
|
|
414
|
+
const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
|
|
415
|
+
level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
|
|
416
|
+
review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())), batch: { on: projectBatchChecks(store, repo).on },
|
|
417
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
418
|
+
return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
419
|
+
}
|
|
420
|
+
// Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
|
|
421
|
+
if (url.pathname === "/settings/backups") {
|
|
422
|
+
const databaseFile = store.databaseFile();
|
|
423
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
424
|
+
return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
|
|
425
|
+
const settings = store.backupSettings();
|
|
426
|
+
const view = { settings, folder: backupFolderOf(settings, databaseFile), defaultFolder: defaultBackupFolder(databaseFile), runs: store.backupRuns(20), csrf: who.session.csrf, now };
|
|
427
|
+
return sendScreen(response, 200, screen("Backups", `<p><a href="/settings">Settings</a></p><h1>Backups</h1>${backupHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
428
|
+
}
|
|
429
|
+
// v105: Settings → Data, where an instance operator downloads everything. The download is a POST with the password.
|
|
430
|
+
if (url.pathname === "/settings/data") {
|
|
431
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
432
|
+
return refuse(response, who, 403, "An instance operator exports data.", "/settings");
|
|
433
|
+
return sendScreen(response, 200, screen("Data", `<p><a href="/settings">Settings</a></p><h1>Data</h1>${dataExportHtml(who.session.csrf, { problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
434
|
+
}
|
|
435
|
+
// v100: Settings → Sign-in, the identity provider people sign in with. An instance operator's page.
|
|
436
|
+
if (url.pathname === "/settings/sign-in") {
|
|
437
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
438
|
+
return refuse(response, who, 403, "An instance operator sets up sign-in.", "/settings");
|
|
439
|
+
const origin = consoleOrigin(request.headers.host);
|
|
440
|
+
const projects = consoleProjects().map(path => ({ path, name: projectName(path) }));
|
|
441
|
+
const settings = ssoSettings();
|
|
442
|
+
const html = ssoSettingsHtml({ settings, redirect: origin === null ? null : `${origin}${SSO_CALLBACK}`, projects, linked: settings !== null && store.ssoIdentitiesOf(who.name).some(one => one.issuer.replace(/\/+$/, "") === settings.issuer) }, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
443
|
+
return sendScreen(response, 200, screen("Sign-in", `<p><a href="/settings">Settings</a></p><h1>Sign-in</h1>${html}`, { chrome: chromeFor(project, "settings") }));
|
|
444
|
+
}
|
|
445
|
+
if (url.pathname === "/settings/knowledge") {
|
|
446
|
+
const projects = consoleProjects();
|
|
447
|
+
const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
|
|
448
|
+
if (chosen && (!projects.includes(chosen) || !visible(chosen)))
|
|
449
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/knowledge");
|
|
450
|
+
const selector = projects.length > 1 ? `<form class="knowledge" method="get" action="/settings/knowledge"><label>Project<select name="repo">${projects.map(p => `<option value="${escape(p)}"${p === chosen ? ' selected' : ''}>${escape(p.split('/').at(-1) ?? p)}</option>`).join('')}</select></label><button>Show project</button></form>` : '';
|
|
451
|
+
let content = '<p>Add a project to keep its knowledge here.</p>';
|
|
452
|
+
if (chosen) {
|
|
453
|
+
try {
|
|
454
|
+
const csrf = who.via === 'cookie' ? who.session.csrf : '';
|
|
455
|
+
const view = knowledgeView(store, chosen, who.name);
|
|
456
|
+
if (url.searchParams.has('version')) {
|
|
457
|
+
const revision = Number(url.searchParams.get('version'));
|
|
458
|
+
const previous = knowledgeVersion(store, chosen, who.name, revision);
|
|
459
|
+
const restore = csrf && who.role === 'approver' && revision !== view.revision ? `<form class="knowledge" method="post" action="/settings/knowledge/change">${hiddenFields({ csrf, repo: chosen, identity: view.identity, revision: String(view.revision), action: 'restore', restore: String(revision) })}<p>Replaces knowledge for future tasks. Existing runs keep their saved context.</p><button>Restore version ${revision}</button></form>` : '';
|
|
460
|
+
return sendScreen(response, 200, screen('Knowledge history', `<p><a href="/settings/knowledge?repo=${encodeURIComponent(chosen)}">Current knowledge</a></p><h1>Version ${revision}</h1>${knowledgeHtml({ ...view, knowledge: previous, history: [], stale: null }, '', false)}${restore}`, { chrome: chromeFor(chosen, 'settings') }));
|
|
461
|
+
}
|
|
462
|
+
const query = (url.searchParams.get('q') ?? '').slice(0, 1000);
|
|
463
|
+
const result = query.trim() ? repositoryContextRead({ repo: chosen, query, mode: url.searchParams.get('mode') === 'impact' ? 'impact' : 'search', cacheRoot: join(dirname(evidenceRoot), 'repository-context') }) : null;
|
|
464
|
+
const memoryQuery = (url.searchParams.get('memory') ?? '').slice(0, 300);
|
|
465
|
+
const hits = memoryQuery.trim() ? searchMemory(store, { actor: who.name, repos: [chosen], query: memoryQuery, limit: 20 }) : null;
|
|
466
|
+
let proposals = '';
|
|
467
|
+
try {
|
|
468
|
+
proposals = proposalsHtml(chosen, listProposals(store, chosen), memoryStatus(store, chosen, who.name), csrf, who.role === 'approver');
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
proposals = '';
|
|
472
|
+
}
|
|
473
|
+
content = `<p class="meta">${MEMORY_INTRO}</p>` + memorySearchHtml(chosen, memoryQuery, hits) + proposals + decisionsHtml(chosen, listDecisions(store, chosen, who.name, { limit: 50 }), csrf, who.role === 'approver')
|
|
474
|
+
+ repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf : '') + knowledgeHtml(view, csrf, who.role === 'approver');
|
|
475
|
+
content += `<details class="knowledge"><summary>Learned lessons</summary>${learningHtml(learningView(store, evidenceRoot, chosen, who.name), csrf, who.role === 'approver')}</details>`;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
content = '<p class="problem" role="alert">Project knowledge is unavailable. Reload to retry.</p>';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return sendScreen(response, 200, screen('Knowledge', `<p><a href="/settings">Settings</a></p><h1>Knowledge</h1>${url.searchParams.get('saved') === '1' ? '<p role="status">Saved for future tasks.</p>' : ''}${selector}${content}`, { chrome: chromeFor(chosen || project, 'settings') }));
|
|
482
|
+
}
|
|
483
|
+
if (url.pathname === "/settings/learning") {
|
|
484
|
+
const projects = consoleProjects();
|
|
485
|
+
const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
|
|
486
|
+
if (chosen && (!projects.includes(chosen) || !visible(chosen)))
|
|
487
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
|
|
488
|
+
const selector = `<form method="get" action="/settings/learning"><label>Project<select name="repo">${projects.map(p => `<option value="${escape(p)}"${p === chosen ? " selected" : ""}>${escape(p.split("/").at(-1) ?? p)}</option>`).join("")}</select></label><button>Show project</button></form>`;
|
|
489
|
+
let content = "<p>No project is available.</p>";
|
|
490
|
+
if (chosen) {
|
|
491
|
+
try {
|
|
492
|
+
content = learningHtml(learningView(store, evidenceRoot, chosen, who.name, Math.max(0, Number(url.searchParams.get("before")) || 0)), who.via === "cookie" ? who.session.csrf : "", who.role === "approver");
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
content = '<p class="problem" role="alert">Learning is unavailable. Reload to retry. Task results are unchanged.</p>';
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return sendScreen(response, 200, screen("Learning", `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
499
|
+
}
|
|
500
|
+
// Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
|
|
501
|
+
if (url.pathname === "/settings/lead") {
|
|
502
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
503
|
+
return refuse(response, who, 403, "An approver sets up the lead.", "/settings");
|
|
504
|
+
if (runtime.localSignIn === null)
|
|
505
|
+
await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
|
|
506
|
+
const config = store.getChatConfig();
|
|
507
|
+
const facts = {
|
|
508
|
+
keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
|
|
509
|
+
const found = chatKeyFor(one);
|
|
510
|
+
return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
|
|
511
|
+
}),
|
|
512
|
+
openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
|
|
513
|
+
liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
|
|
514
|
+
csrf: who.session.csrf,
|
|
515
|
+
returnTo: "/settings/lead",
|
|
516
|
+
};
|
|
517
|
+
const signedIn = runtime.localSignIn?.states.claude === "connected" ? "Claude Code" : runtime.localSignIn?.states.codex === "connected" ? "Codex" : null;
|
|
518
|
+
const promises = openCommitments(store, who.name, 50).map(one => ({ id: one.id, what: one.what, when: conditionWords(store, one.condition), until: one.expiresAt }));
|
|
519
|
+
return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
|
|
520
|
+
said: url.searchParams.get("said"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises,
|
|
521
|
+
about: aboutYouOf(store, who.name), aboutSaved: url.searchParams.get("saved") === "about" }), { chrome: chromeFor(project, "settings") }));
|
|
522
|
+
}
|
|
523
|
+
if (url.pathname === "/settings/telegram") {
|
|
524
|
+
// Any approver pairs their OWN phone here; the bot token stays on /settings.
|
|
525
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
526
|
+
return refuse(response, who, 403, "An approver can pair their own phone.", "/settings");
|
|
527
|
+
const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
|
|
528
|
+
// The settings view holds no secret (a pairing code only appears in
|
|
529
|
+
// the response to Pair, which stays script-free), so it joins the
|
|
530
|
+
// workspace like every other settings page.
|
|
531
|
+
return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { now }), { chrome: chromeFor(project, "settings") }));
|
|
532
|
+
}
|
|
533
|
+
if (url.pathname === "/settings/teams") {
|
|
534
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
535
|
+
return refuse(response, who, 403, "An installation approver can connect Teams.", "/settings");
|
|
536
|
+
return sendScreen(response, 200, screen("Teams", teamsSettingsHtml(store, options.configDir, who.session.csrf, { who: who.name, publicUrl: options.publicUrl ?? null }), { chrome: chromeFor(project, "settings") }));
|
|
537
|
+
}
|
|
538
|
+
if (url.pathname === "/settings/discord") {
|
|
539
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
540
|
+
return refuse(response, who, 403, "An installation approver can connect Discord.", "/settings");
|
|
541
|
+
return sendScreen(response, 200, screen("Discord", discordSettingsHtml(store, options.configDir, who.session.csrf, { who: who.name }), { chrome: chromeFor(project, "settings") }));
|
|
542
|
+
}
|
|
543
|
+
if (url.pathname === "/settings/slack" || url.pathname === "/settings/slack/manifest") {
|
|
544
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
545
|
+
return refuse(response, who, 403, "An installation approver can connect Slack.", "/settings");
|
|
546
|
+
if (url.pathname.endsWith("/manifest")) {
|
|
547
|
+
response.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Content-Disposition": 'attachment; filename="standing-orders-slack.json"', "Cache-Control": "no-store" });
|
|
548
|
+
response.end(JSON.stringify(SLACK_MANIFEST, null, 2));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
return sendScreen(response, 200, screen("Slack", slackSettingsHtml(store, options.configDir, who.session.csrf, { who: who.name }), { chrome: chromeFor(project, "settings") }));
|
|
552
|
+
}
|
|
553
|
+
if (url.pathname === "/settings" && (options.telegramTokenFile === undefined || restricted())) {
|
|
554
|
+
return sendScreen(response, 200, screen("Settings", '<h1>Settings</h1><p><a href="/settings/models">Models</a> · <a href="/settings/skills">Skills</a> · <a href="/settings/tools">Tools</a> · <a href="/settings/knowledge">Project knowledge</a> · <a href="/settings/telegram">Telegram</a></p><details><summary>Learning history</summary><a href="/settings/learning">Learning</a></details>', { chrome: chromeFor(project, "settings") }));
|
|
555
|
+
}
|
|
556
|
+
if (url.pathname === "/settings" && options.telegramTokenFile !== undefined) {
|
|
557
|
+
const existing = loadBotToken({}, options.telegramTokenFile);
|
|
558
|
+
const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
|
|
559
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
560
|
+
const messaging = options.configDir === undefined
|
|
561
|
+
? null
|
|
562
|
+
: effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null);
|
|
563
|
+
const push = {
|
|
564
|
+
// The card lights only where a secure context exists: the stated
|
|
565
|
+
// public origin, or localhost development (arc 3 finding 2).
|
|
566
|
+
available: options.publicUrl !== undefined || (request.headers.host ?? "").startsWith("localhost") || (request.headers.host ?? "").startsWith("127.0.0.1"),
|
|
567
|
+
devices: who.via === "cookie" ? store.listPushSubscriptions(who.name) : [],
|
|
568
|
+
};
|
|
569
|
+
const providerKeys = who.role !== "approver"
|
|
570
|
+
? null
|
|
571
|
+
: await Promise.all(PROVIDER_IDS.map(async (provider) => ({
|
|
572
|
+
provider,
|
|
573
|
+
envName: PROVIDER_KEY_ENV[provider],
|
|
574
|
+
...keyStatus(provider, providerHome),
|
|
575
|
+
connection: await connectionCheck(provider, url.searchParams.get("check-connection") === provider),
|
|
576
|
+
ambient: (process.env[PROVIDER_KEY_ENV[provider]] ?? "") !== "",
|
|
577
|
+
mode: readAuthMode(provider, providerHome),
|
|
578
|
+
subscriptionCapable: SUBSCRIPTION_CAPABLE[provider],
|
|
579
|
+
})));
|
|
580
|
+
const telegramConfigured = loadBotToken(process.env, options.telegramTokenFile) !== null;
|
|
581
|
+
const digest = telegramConfigured
|
|
582
|
+
? (() => {
|
|
583
|
+
const cadence = store.telegramDigest();
|
|
584
|
+
return { everyMs: cadence.everyMs, lastSentAt: cadence.lastSentAt, held: store.countRoutinePending() };
|
|
585
|
+
})()
|
|
586
|
+
: null;
|
|
587
|
+
return sendScreen(response, 200, settingsPage(chromeFor(project, "settings"), existing, hasEnv, csrf, url.searchParams.get("said"), messaging, push, providerKeys, digest, {
|
|
588
|
+
...store.permissionDefault(),
|
|
589
|
+
canManage: who.role === "approver",
|
|
590
|
+
}, {
|
|
591
|
+
...store.qualityDefault(),
|
|
592
|
+
canManage: who.role === "approver",
|
|
593
|
+
}, who.role === "approver" && csrf !== "" ? (() => {
|
|
594
|
+
// v87: the mail server flows send email through; the password is never shown back.
|
|
595
|
+
// v89: where Inbox triggers read it, and a connected Google account (its client secret is never shown back either).
|
|
596
|
+
const email = readEmailSettings(options.configDir ?? null), google = readGoogleMail(options.configDir ?? null), origin = consoleOrigin(request.headers.host);
|
|
597
|
+
const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
|
|
598
|
+
return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
|
|
599
|
+
: { set: true, host: email.host, port: email.port, secure: email.secure, user: email.user, from: email.from, imapHost: email.imap?.host ?? "", imapPort: email.imap?.port ?? 993, google: googleView };
|
|
600
|
+
})() : null, telegramDeliveryWords(store, loadBotToken(process.env, options.telegramTokenFile)), settingsWorkers(store, now), settingsUpdates(who.name, csrf), (() => {
|
|
601
|
+
// The installation fact: when the first Ready result arrived, measured from the first account.
|
|
602
|
+
const at = store.firstSuccessAt(now), since = store.installationStartedAt();
|
|
603
|
+
return at === null || since === null ? null : firstResultWords(since, at);
|
|
604
|
+
})(), (() => {
|
|
605
|
+
const chosen = store.notificationPreference(who.name);
|
|
606
|
+
return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
|
|
607
|
+
})(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile)), who.via === "cookie" ? phoneSetup(who) ?? null : null));
|
|
608
|
+
}
|
|
609
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
610
|
+
}
|
|
611
|
+
async function post(ctx) {
|
|
612
|
+
const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
|
|
613
|
+
// A person says a provider's sign-in works again: its sign-in pause
|
|
614
|
+
// lifts, its tasks may start, and one short message says so.
|
|
615
|
+
const resumeProvider = /^\/providers\/([a-z]+)\/resume$/.exec(url.pathname);
|
|
616
|
+
if (resumeProvider !== null) {
|
|
617
|
+
const provider = resumeProvider[1];
|
|
618
|
+
if (!isProviderId(provider))
|
|
619
|
+
return refuse(response, who, 400, "unknown provider", "/work");
|
|
620
|
+
if (who.role !== "approver")
|
|
621
|
+
return refuse(response, who, 403, "Only an operator can resume an agent's work.", "/work");
|
|
622
|
+
liftAuthPause(store, provider, "person", who.name, clock());
|
|
623
|
+
return redirect(response, "/work");
|
|
624
|
+
}
|
|
625
|
+
// The update notice is dismissed per browser and per version: a newer release shows it again.
|
|
626
|
+
if (url.pathname === "/settings/updates/dismiss") {
|
|
627
|
+
const body = readForm(posted, CONSOLE_FORMS.updatesDismiss);
|
|
628
|
+
const version = body.get("version") ?? "";
|
|
629
|
+
if (!/^[0-9A-Za-z.-]{1,60}$/.test(version))
|
|
630
|
+
return refuse(response, who, 400, "Choose a version to dismiss.", "/settings#updates");
|
|
631
|
+
response.setHeader("Set-Cookie", `so-update-seen=${version}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
632
|
+
if (body.get("quiet") === "1") {
|
|
633
|
+
response.writeHead(204);
|
|
634
|
+
response.end();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
return redirect(response, safeReturn(body.get("return") ?? "/settings#updates"));
|
|
638
|
+
}
|
|
639
|
+
// The daily check's switch: an installation setting, so an operator's. (Its own address: POST /settings/updates starts an update.)
|
|
640
|
+
if (url.pathname === "/settings/updates/checks") {
|
|
641
|
+
const body = readForm(posted, CONSOLE_FORMS.updatesChecks);
|
|
642
|
+
if (options.configDir === undefined)
|
|
643
|
+
return refuse(response, who, 404, "Updates are not set up on this console.", "/settings");
|
|
644
|
+
if (!store.isInstanceOperator(who.name))
|
|
645
|
+
return refuse(response, who, 403, "Only an operator can change update checks.", "/settings#updates");
|
|
646
|
+
const on = body.get("check") === "on";
|
|
647
|
+
setUpdateChecks(options.configDir, on);
|
|
648
|
+
return redirect(response, `/settings?said=${encodeURIComponent(on ? "Toolroll looks for a newer version once a day." : "Update checks are off.")}#updates`);
|
|
649
|
+
}
|
|
650
|
+
if (url.pathname === "/settings/skills/revise") {
|
|
651
|
+
const body = readForm(posted, CONSOLE_FORMS.skillsRevise);
|
|
652
|
+
if (who.via !== 'cookie' || who.role !== 'approver')
|
|
653
|
+
return refuse(response, who, 403, 'Sign in to revise a test.', '/settings/skills');
|
|
654
|
+
try {
|
|
655
|
+
const run = Number(body.get('run')), source = skillTestResult(store, run, who.name);
|
|
656
|
+
if (!source || !visible(source.repo) || body.get('repo') !== source.repo || ['run', 'repo', 'nonce', 'feedback'].some(k => body.getAll(k).length !== 1))
|
|
657
|
+
return refuse(response, who, 403, 'That skill test is outside your access.', '/settings/skills');
|
|
658
|
+
try {
|
|
659
|
+
const revision = reviseSkillTest(store, { run, actor: who.name, feedback: body.get('feedback') ?? '', nonce: body.get('nonce') ?? '' }, clock());
|
|
660
|
+
return redirect(response, `/t/${encodeURIComponent(revision.id)}`);
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
return sendScreen(response, 409, screen('Revise skill test', skillTestFeedbackHtml(source, who.session.csrf, true, error instanceof Error ? error.message : 'The test could not be created.', body.get('feedback') ?? ''), { chrome: chromeFor(source.repo, 'settings') }));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return refuse(response, who, 409, 'The source test could not be verified. Open its result and try again.', '/settings/skills');
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (url.pathname === "/settings/skills/import" || url.pathname === "/settings/skills/change") {
|
|
671
|
+
const body = readForm(posted, CONSOLE_FORMS.skillsChange);
|
|
672
|
+
if (who.via !== 'cookie' || who.role !== 'approver')
|
|
673
|
+
return refuse(response, who, 403, 'Sign in as an approver to manage skills.', '/settings/skills');
|
|
674
|
+
const repo = body.get('repo') ?? '', back = `/settings/skills?repo=${encodeURIComponent(repo)}`;
|
|
675
|
+
if (!visible(repo) || !consoleProjects().includes(repo))
|
|
676
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/projects');
|
|
677
|
+
if ([...new Set(body.keys())].some(k => body.sent.getAll(k).length !== 1))
|
|
678
|
+
return refuse(response, who, 400, 'Invalid skills form.', back);
|
|
679
|
+
try {
|
|
680
|
+
const view = skillsView(store, repo, who.name);
|
|
681
|
+
if (body.get('identity') !== view.identity || body.get('revision') !== String(view.revision))
|
|
682
|
+
throw Error('Skills changed in another window. Review the current selection and try again.');
|
|
683
|
+
if (url.pathname.endsWith('/import')) {
|
|
684
|
+
const method = body.get('method');
|
|
685
|
+
let files, source;
|
|
686
|
+
if (method === 'paste') {
|
|
687
|
+
files = [{ path: 'SKILL.md', base64: Buffer.from(body.get('content') ?? '').toString('base64') }];
|
|
688
|
+
source = 'Pasted SKILL.md';
|
|
689
|
+
}
|
|
690
|
+
else if (method === 'folder') {
|
|
691
|
+
files = JSON.parse(body.get('files') ?? '[]');
|
|
692
|
+
source = 'Uploaded local folder';
|
|
693
|
+
}
|
|
694
|
+
else if (method === 'github') {
|
|
695
|
+
const imported = await githubSkill(body.get('url') ?? '');
|
|
696
|
+
files = imported.files;
|
|
697
|
+
source = imported.source;
|
|
698
|
+
}
|
|
699
|
+
else
|
|
700
|
+
throw Error('Choose how to add the skill.');
|
|
701
|
+
const skill = importSkill(store, repo, who.name, files, source, clock());
|
|
702
|
+
return redirect(response, `${back}&skill=${skill.sha}#skill-${skill.sha}`);
|
|
703
|
+
}
|
|
704
|
+
const action = body.get('action');
|
|
705
|
+
if (action === 'test') {
|
|
706
|
+
const task = testSkill(store, { repo, actor: who.name, sha: body.get('sha') ?? '', sample: body.get('sample') ?? '', nonce: body.get('nonce') ?? '' }, clock());
|
|
707
|
+
return redirect(response, `/t/${encodeURIComponent(task.id)}`);
|
|
708
|
+
}
|
|
709
|
+
if (action !== 'enable' && action !== 'disable' && action !== 'restore')
|
|
710
|
+
throw Error('Choose a supported skills action.');
|
|
711
|
+
changeSkills(store, { repo, actor: who.name, identity: view.identity, revision: view.revision, action, sha: body.get('sha') ?? '', restore: Number(body.get('restore')) }, clock());
|
|
712
|
+
return redirect(response, `${back}&saved=1`);
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
const message = error instanceof Error ? error.message : 'Skills could not be saved. Try again.';
|
|
716
|
+
let content = `<p role="alert">${escape(message)}</p><a href="${escape(back)}">Reload Skills</a>`;
|
|
717
|
+
try {
|
|
718
|
+
content = skillsHtml(skillsView(store, repo, who.name), who.session.csrf, true, { error: message, draft: Object.fromEntries(['method', 'content', 'url', 'sample', 'sha'].map(k => [k, body.get(k) ?? ''])) });
|
|
719
|
+
}
|
|
720
|
+
catch { /* Do not display unverified packages. */ }
|
|
721
|
+
return sendScreen(response, 409, screen('Skills', `<h1>Skills</h1>${content}`, { chrome: chromeFor(repo, 'settings'), functional: { script: skillsScript() } }));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// v103: an instance operator records a checkpoint of the ledger chain (its head, to copy off the machine).
|
|
725
|
+
// v105: set or remove a monthly budget. An instance operator, with a step-up; the ledger keeps before → after.
|
|
726
|
+
if (url.pathname === "/spend/budget") {
|
|
727
|
+
const body = readForm(posted, CONSOLE_FORMS.spendBudget);
|
|
728
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
729
|
+
return refuse(response, who, 403, "An instance operator sets budgets.", "/spend");
|
|
730
|
+
const back = (key, words) => redirect(response, `/spend?${key}=${encodeURIComponent(words)}`);
|
|
731
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
732
|
+
return back("problem", "Enter your Toolroll password to change a budget.");
|
|
733
|
+
const target = /^(installation|project|person|teammate):(.+)$/.exec(body.get("target") ?? "");
|
|
734
|
+
if (target === null)
|
|
735
|
+
return back("problem", "Choose what the budget is for.");
|
|
736
|
+
const scope = target[1], key = target[2];
|
|
737
|
+
const known = scope === "installation" ? key === "*"
|
|
738
|
+
: scope === "project" ? consoleProjects().includes(key)
|
|
739
|
+
: scope === "person" ? store.accountFacts().some(one => one.name === key && one.revokedAt === null)
|
|
740
|
+
: store.teammates([...new Set([...managedRepos(), ...store.knownRepos()])]).some(one => String(one.id) === key);
|
|
741
|
+
if (!known)
|
|
742
|
+
return back("problem", "That isn't a project, person or teammate here.");
|
|
743
|
+
const label = budgetLabel({ scope, key }).replace(/'s$/, "");
|
|
744
|
+
if (body.get("action") === "remove") {
|
|
745
|
+
const existing = store.budgets().find(one => one.scope === scope && one.key === key);
|
|
746
|
+
if (existing === undefined || !store.removeBudget(existing.id, who.name, now))
|
|
747
|
+
return back("problem", "That budget is already gone.");
|
|
748
|
+
return back("said", `${label}: budget removed.`);
|
|
749
|
+
}
|
|
750
|
+
const dollars = Number(body.get("usd") ?? "");
|
|
751
|
+
if (!Number.isFinite(dollars) || dollars < 1 || dollars > 10_000_000)
|
|
752
|
+
return back("problem", "A monthly limit is a whole number of dollars, at least $1.");
|
|
753
|
+
const saved = store.setBudget({ scope, key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: body.get("stop") === "1" }, who.name, now);
|
|
754
|
+
return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
|
|
755
|
+
}
|
|
756
|
+
// Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
|
|
757
|
+
// Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
|
|
758
|
+
if (url.pathname === "/settings/project/concurrency") {
|
|
759
|
+
const body = readForm(posted, CONSOLE_FORMS.projectConcurrency);
|
|
760
|
+
const repo = body.get("repo") ?? "";
|
|
761
|
+
const known = consoleProjects();
|
|
762
|
+
if (!known.includes(repo))
|
|
763
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
764
|
+
if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
|
|
765
|
+
return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
|
|
766
|
+
const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
767
|
+
const n = parseProjectConcurrency(body.get("concurrency") ?? "");
|
|
768
|
+
if (n === null)
|
|
769
|
+
return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
|
|
770
|
+
const databaseFile = store.databaseFile();
|
|
771
|
+
if (databaseFile === null)
|
|
772
|
+
return back("problem", "This database keeps no settings files. Nothing changed.");
|
|
773
|
+
const changed = saveProjectConcurrency(databaseFile, repo, n);
|
|
774
|
+
store.recordProjectConcurrency(who.name, repo, changed.before, changed.after, now);
|
|
775
|
+
store.bumpWake();
|
|
776
|
+
return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
|
|
777
|
+
}
|
|
778
|
+
if (url.pathname === "/settings/project/delete") {
|
|
779
|
+
const body = readForm(posted, CONSOLE_FORMS.projectDelete);
|
|
780
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
781
|
+
return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
|
|
782
|
+
const repo = body.get("repo") ?? "";
|
|
783
|
+
const known = consoleProjects();
|
|
784
|
+
if (!known.includes(repo))
|
|
785
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
786
|
+
const back = (key, words, to = repo) => redirect(response, `/settings/project?${to === "" ? "" : `repo=${encodeURIComponent(to)}&`}${key}=${encodeURIComponent(words)}`);
|
|
787
|
+
const view = projectViewOf(repo, who);
|
|
788
|
+
if ((body.get("name") ?? "").trim() !== view.name)
|
|
789
|
+
return back("problem", `Type ${view.name} exactly to delete it.`);
|
|
790
|
+
const confirm = (problem) => sendScreen(response, 200, screen(`Delete ${view.name}?`, `<p><a href="/settings/project?repo=${escape(encodeURIComponent(repo))}">${escape(view.name)}</a></p><h1>Delete ${escape(view.name)}?</h1>${projectDeleteConfirmHtml(view, who.session.csrf, problem)}`, { chrome: chromeFor(repo, "settings"), forceSensitive: true }));
|
|
791
|
+
if (view.running.length > 0)
|
|
792
|
+
return back("problem", `Nothing was deleted: ${view.running.join(", ")}. Stop it, then try again.`);
|
|
793
|
+
if (body.get("step") !== "delete")
|
|
794
|
+
return confirm(null);
|
|
795
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
796
|
+
return confirm("That password didn't match. Nothing was deleted.");
|
|
797
|
+
const done = await deleteProject(store, repo, { actor: who.name, via: "console", now, evidenceRoot, poolRoot: options.poolRoot ?? null });
|
|
798
|
+
if (!done.ok)
|
|
799
|
+
return back("problem", done.said);
|
|
800
|
+
if (options.registryPath !== undefined)
|
|
801
|
+
await updateRepos(options.registryPath, repos => removeRepos(repos, [repo])).catch(() => undefined);
|
|
802
|
+
deletedRepos.add(repo);
|
|
803
|
+
if (who.session.project === repo) {
|
|
804
|
+
who.session.project = null;
|
|
805
|
+
who.session.projectRevision += 1;
|
|
806
|
+
sessions.persist(who.session);
|
|
807
|
+
}
|
|
808
|
+
return back("said", `Deleted ${view.name}: ${holdingsWords(done.removed)}.${done.left.length > 0 ? ` Git kept ${done.left.length === 1 ? "one item" : `${done.left.length} items`}: ${done.left.join("; ")}.` : ""}`, "");
|
|
809
|
+
}
|
|
810
|
+
// Sprint 8: an instance operator saves the organisation policy, with a step-up; the ledger keeps each rule's before → after.
|
|
811
|
+
if (url.pathname === "/settings/policy") {
|
|
812
|
+
const body = readForm(posted, CONSOLE_FORMS.policy);
|
|
813
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
814
|
+
return refuse(response, who, 403, "An instance operator sets the policy.", "/settings/policy");
|
|
815
|
+
const back = (key, words) => redirect(response, `/settings/policy?${key}=${encodeURIComponent(words)}`);
|
|
816
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
817
|
+
return back("problem", "That password didn't match. Nothing changed.");
|
|
818
|
+
const chosen = body.getAll("provider");
|
|
819
|
+
const checked = checkPolicy({ providers: chosen.length === 4 ? null : chosen, models: parseList(body.get("models")), tools: parseList(body.get("tools")), ceiling: body.get("ceiling") ?? "" });
|
|
820
|
+
if (!checked.ok)
|
|
821
|
+
return back("problem", `${checked.problem} Nothing changed.`);
|
|
822
|
+
const before = policyParts(store.orgPolicy());
|
|
823
|
+
const after = policyParts(store.setOrgPolicy(checked.policy, who.name, now));
|
|
824
|
+
const same = Object.keys(before).every(rule => before[rule] === after[rule]);
|
|
825
|
+
return back("said", same ? "Nothing changed." : "Policy saved.");
|
|
826
|
+
}
|
|
827
|
+
// v102: an instance operator changes a project's approval rules, with a step-up; the ledger keeps before → after.
|
|
828
|
+
if (url.pathname === "/settings/approval") {
|
|
829
|
+
const body = readForm(posted, CONSOLE_FORMS.approval);
|
|
830
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
831
|
+
return refuse(response, who, 403, "An instance operator sets approval rules.", "/settings/approval");
|
|
832
|
+
const repo = body.get("repo") ?? "";
|
|
833
|
+
const known = consoleProjects();
|
|
834
|
+
if (!known.includes(repo))
|
|
835
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
|
|
836
|
+
const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
837
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
838
|
+
return back("problem", "That password didn't match. Nothing changed.");
|
|
839
|
+
const protect = body.get("protect") ?? "none";
|
|
840
|
+
const paths = parseProtectedPaths(body.get("paths") ?? "");
|
|
841
|
+
if (!paths.ok)
|
|
842
|
+
return back("problem", paths.problem);
|
|
843
|
+
if (protect === "paths" && paths.paths.length === 0)
|
|
844
|
+
return back("problem", "List at least one path to protect, or protect the whole project.");
|
|
845
|
+
const next = { notRequester: body.get("not_requester") === "1", protectProject: protect === "project", protectedPaths: protect === "paths" ? paths.paths : [] };
|
|
846
|
+
store.setApprovalRules(repo, next, who.name, now);
|
|
847
|
+
return back("said", `Saved. ${rulesSummary(next)}`);
|
|
848
|
+
}
|
|
849
|
+
// v112: request limits for API tokens (request-budget.ts). An instance operator, in a browser, with their password;
|
|
850
|
+
// never over /api/cli or /mcp (a token never reaches a console form).
|
|
851
|
+
if (url.pathname === "/settings/request-limits") {
|
|
852
|
+
const body = readForm(posted, CONSOLE_FORMS.requestLimits);
|
|
853
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
854
|
+
return refuse(response, who, 403, "An instance operator sets request limits.", "/settings/sessions");
|
|
855
|
+
const back = (key, words) => redirect(response, `/settings/sessions?${key}=${encodeURIComponent(words)}`);
|
|
856
|
+
const target = body.get("target") ?? "";
|
|
857
|
+
const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
|
|
858
|
+
if (target !== "*" && token === null)
|
|
859
|
+
return back("problem", "That token is no longer live. Nothing changed.");
|
|
860
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
861
|
+
return back("problem", "Enter your Toolroll password to change request limits.");
|
|
862
|
+
const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
|
|
863
|
+
const clear = body.get("action") === "clear";
|
|
864
|
+
const fields = clear ? { readPerMinute: null, actPerMinute: null, perDay: null }
|
|
865
|
+
: token === null ? { readPerMinute: read("read-per-minute"), actPerMinute: read("act-per-minute"), perDay: read("per-day") }
|
|
866
|
+
: { readPerMinute: token.access === "read" ? read("per-minute") : null, actPerMinute: token.access === "act" ? read("per-minute") : null, perDay: read("per-day") };
|
|
867
|
+
const problem = Object.values(fields).find((value) => value !== null && typeof value === "object");
|
|
868
|
+
if (problem !== undefined)
|
|
869
|
+
return back("problem", `${problem.problem} Nothing changed.`);
|
|
870
|
+
setLimitOverride(store, target, fields, who.name, now);
|
|
871
|
+
const label = token === null ? "Everyone's tokens" : token.name;
|
|
872
|
+
return back("said", clear || Object.values(fields).every(value => value === null) ? `${label}: default limits.` : `${label}: limits saved.`);
|
|
873
|
+
}
|
|
874
|
+
if (url.pathname === "/settings/sign-in") {
|
|
875
|
+
const body = readForm(posted, CONSOLE_FORMS.signIn);
|
|
876
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
877
|
+
return refuse(response, who, 403, "An instance operator sets up sign-in.", "/settings");
|
|
878
|
+
const back = (key, words) => redirect(response, `/settings/sign-in?${key}=${encodeURIComponent(words)}`);
|
|
879
|
+
const action = body.get("action") ?? "";
|
|
880
|
+
const before = ssoSettings();
|
|
881
|
+
if (action === "test") {
|
|
882
|
+
if (before === null)
|
|
883
|
+
return back("problem", "Sign-in with a provider isn't set up.");
|
|
884
|
+
runtime.ssoProvider = null;
|
|
885
|
+
const found = await providerFor(before.issuer);
|
|
886
|
+
return found.ok ? back("said", `${before.label} answers: it's ready for people to sign in.`) : back("problem", found.said);
|
|
887
|
+
}
|
|
888
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
889
|
+
return back("problem", "Enter your Toolroll password to change sign-in.");
|
|
890
|
+
if (action === "remove") {
|
|
891
|
+
removeSsoSettings(options.configDir);
|
|
892
|
+
runtime.ssoProvider = null;
|
|
893
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "sign-in with a provider turned off", outcome: "changed", source: "policy", detail: ssoChangeWords(before, null) });
|
|
894
|
+
return back("said", "Sign-in with the provider is off. Passwords work for everyone again.");
|
|
895
|
+
}
|
|
896
|
+
if (action !== "save")
|
|
897
|
+
return back("problem", "Choose a sign-in action.");
|
|
898
|
+
const rules = [];
|
|
899
|
+
for (let index = 0; index < 50 && body.has(`group-${index}`); index++)
|
|
900
|
+
rules.push({ group: body.get(`group-${index}`) ?? "", role: body.get(`role-${index}`) ?? "", projects: body.getAll(`projects-${index}`) });
|
|
901
|
+
const saved = saveSsoSettings(options.configDir, { issuer: body.get("issuer") ?? "", clientId: body.get("client-id") ?? "", clientSecret: body.get("client-secret") ?? "", label: body.get("label") ?? "",
|
|
902
|
+
scopes: body.get("scopes") ?? "", groupsClaim: body.get("groups-claim") ?? "", passwords: body.get("passwords") ?? "", rules }, before);
|
|
903
|
+
if (!saved.ok)
|
|
904
|
+
return back("problem", saved.said);
|
|
905
|
+
// The provider has to answer before anyone depends on it.
|
|
906
|
+
runtime.ssoProvider = null;
|
|
907
|
+
const found = await providerFor(saved.settings.issuer);
|
|
908
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: before === null ? "sign-in with a provider turned on" : "sign-in with a provider changed", outcome: "changed", source: "policy", detail: ssoChangeWords(before, saved.settings) });
|
|
909
|
+
return found.ok ? back("said", `Saved. People can sign in with ${saved.settings.label}.`) : back("problem", `Saved, but ${saved.settings.label} didn't answer: ${found.said}`);
|
|
910
|
+
}
|
|
911
|
+
// Settings → Updates: start the `toolroll update` job. A step-up; the job writes the ledger entries.
|
|
912
|
+
if (url.pathname === "/settings/updates" || url.pathname === "/settings/updates/seen" || url.pathname === "/settings/updates/cancel") {
|
|
913
|
+
const body = readForm(posted, CONSOLE_FORMS.updates);
|
|
914
|
+
const databaseFile = store.databaseFile();
|
|
915
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
916
|
+
return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
|
|
917
|
+
const stateDir = dirname(databaseFile);
|
|
918
|
+
const back = (key, words) => redirect(response, `/settings/updates?${key}=${encodeURIComponent(words)}`);
|
|
919
|
+
if (url.pathname === "/settings/updates/seen") {
|
|
920
|
+
markWhatsNewSeen(stateDir);
|
|
921
|
+
return redirect(response, "/settings/updates");
|
|
922
|
+
}
|
|
923
|
+
if (url.pathname === "/settings/updates/cancel")
|
|
924
|
+
return back("said", requestRuntimeUpdateCancel(stateDir));
|
|
925
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
926
|
+
return back("problem", "Enter your Toolroll password to update.");
|
|
927
|
+
const version = body.get("version") ?? "";
|
|
928
|
+
const when = { now: "now", "when-idle": "when-idle", tonight: "at" }[body.get("when") ?? ""] ?? null;
|
|
929
|
+
if (!/^\d+\.\d+\.\d+$/.test(version) || when === null)
|
|
930
|
+
return back("problem", "Choose when to update.");
|
|
931
|
+
const current = options.updates?.current ?? PACKAGE_VERSION;
|
|
932
|
+
if (!newerThan(version, current))
|
|
933
|
+
return back("problem", `Toolroll ${version} is not newer than ${current}. To go back, run toolroll update --rollback.`);
|
|
934
|
+
const method = options.updates?.method ?? installMethod();
|
|
935
|
+
if (method.kind === "npx" || method.kind === "source" || method.kind === "desktop")
|
|
936
|
+
return back("problem", method.kind === "npx" ? "npx runs the latest release each time; there is nothing to update." : method.kind === "desktop" ? "Update it from the Toolroll app." : "This runs from a source checkout. Update it with git.");
|
|
937
|
+
const before = runtimeUpdateStatus(stateDir).journal;
|
|
938
|
+
if (before !== null && !runtimeUpdateTerminal(before.phase))
|
|
939
|
+
return back("problem", "An update is already under way.");
|
|
940
|
+
if (when === "now") {
|
|
941
|
+
const running = runningWorkWords(store.raw());
|
|
942
|
+
if (running !== null)
|
|
943
|
+
return back("problem", `Work is running: ${running}. Choose When idle to update once it finishes.`);
|
|
944
|
+
}
|
|
945
|
+
const prepared = prepareRuntimeUpdate({ stateDir, databaseFile, current: { version: current, dist: options.updates?.dist ?? currentRuntime(current).dist }, actor: who.name, version, when, at: "03:00" }, clock());
|
|
946
|
+
if ("refused" in prepared)
|
|
947
|
+
return back("problem", prepared.refused);
|
|
948
|
+
try {
|
|
949
|
+
await (options.updates?.launch ?? launchRuntimeUpdate)({ databaseFile, id: prepared.id });
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
abandonRuntimeUpdate(stateDir, prepared.id, `The update could not start: ${error.message}`, clock());
|
|
953
|
+
return back("problem", `The update could not start: ${error.message}`);
|
|
954
|
+
}
|
|
955
|
+
// The job records its first step within moments; show it rather than the old page.
|
|
956
|
+
return back("said", when === "at" ? `Update to ${version} scheduled for 03:00.` : `Updating to ${version}.`);
|
|
957
|
+
}
|
|
958
|
+
// v105: change how long each kind of data is kept. A step-up; the ledger keeps before → after for each kind.
|
|
959
|
+
if (url.pathname === "/settings/retention") {
|
|
960
|
+
const body = readForm(posted, CONSOLE_FORMS.retention);
|
|
961
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
962
|
+
return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
|
|
963
|
+
const back = (key, words) => redirect(response, `/settings/retention?${key}=${encodeURIComponent(words)}`);
|
|
964
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
965
|
+
return back("problem", "Enter your Toolroll password to change retention.");
|
|
966
|
+
const before = store.retentionPeriods();
|
|
967
|
+
// Only what the page offers (or the period already set): a hand-made value doesn't save.
|
|
968
|
+
const offered = (kind, days) => days !== undefined && periodChoices(kind, before[kind]).includes(days) ? days : undefined;
|
|
969
|
+
const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? offered(kind, parsePeriod(body.get(kind) ?? "")) : before[kind] }));
|
|
970
|
+
if (chosen.some(one => one.days === undefined))
|
|
971
|
+
return back("problem", "Choose a period for each kind of data.");
|
|
972
|
+
const changed = chosen.filter(one => one.days !== before[one.kind]);
|
|
973
|
+
for (const one of changed)
|
|
974
|
+
store.setRetentionPeriod(one.kind, one.days ?? null, who.name, now);
|
|
975
|
+
return back("said", changed.length === 0 ? "Nothing changed." : "Saved. The next daily sweep uses these periods.");
|
|
976
|
+
}
|
|
977
|
+
// Settings → Storage: when a finished task's clean checkout goes, cleaning up what the preview showed, and throwing
|
|
978
|
+
// away a checkout kept for its changes. Each takes the password; the ledger keeps every change and removal.
|
|
979
|
+
if (url.pathname === "/settings/storage" || url.pathname === "/settings/storage/clean" || url.pathname === "/settings/storage/discard") {
|
|
980
|
+
const body = readForm(posted, CONSOLE_FORMS.storage);
|
|
981
|
+
const databaseFile = store.databaseFile();
|
|
982
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
983
|
+
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
984
|
+
const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
|
|
985
|
+
const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
|
|
986
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
987
|
+
return back("problem", `Enter your Toolroll password to ${what}.`);
|
|
988
|
+
if (url.pathname === "/settings/storage") {
|
|
989
|
+
const cleanup = parseCleanup(body.get("cleanup") ?? "");
|
|
990
|
+
if (cleanup === undefined)
|
|
991
|
+
return back("problem", "Choose when a finished task's checkout is removed.");
|
|
992
|
+
const before = store.checkoutCleanup();
|
|
993
|
+
store.setCheckoutCleanup(cleanup, who.name, now);
|
|
994
|
+
return back("said", cleanup === before ? "Nothing changed." : "Saved.");
|
|
995
|
+
}
|
|
996
|
+
const pool = storagePool(databaseFile);
|
|
997
|
+
if (url.pathname.endsWith("/clean")) {
|
|
998
|
+
// Only what the preview showed goes: when that changed since, the person looks again.
|
|
999
|
+
const plan = await checkoutPlan(store, pool, clock(), { manual: true });
|
|
1000
|
+
if (plan.go.length === 0)
|
|
1001
|
+
return back("said", "Nothing to clean up.");
|
|
1002
|
+
if (body.get("preview") !== previewDigest(plan))
|
|
1003
|
+
return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
|
|
1004
|
+
const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
|
|
1005
|
+
saveSweep(store, { at: clock().toISOString(), source: "manual", actor: who.name, parts: [{ kind: "checkouts", count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, items: done.removed.slice(0, 20).map(one => one.path) }] });
|
|
1006
|
+
return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
|
|
1007
|
+
}
|
|
1008
|
+
const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
|
|
1009
|
+
return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
|
|
1010
|
+
}
|
|
1011
|
+
// Pull requests for one project: turn on (the checks run again and must match what the page showed), merge
|
|
1012
|
+
// settings, or turn off. Each takes the password and lands in the ledger.
|
|
1013
|
+
if (url.pathname === "/settings/pull-requests") {
|
|
1014
|
+
const body = readForm(posted, CONSOLE_FORMS.pullRequests);
|
|
1015
|
+
const repo = body.get("repo") ?? "";
|
|
1016
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
1017
|
+
if (who.via !== "cookie")
|
|
1018
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
1019
|
+
if (!known.includes(repo) || !visible(repo))
|
|
1020
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
1021
|
+
if (who.role !== "approver" || store.isDemo())
|
|
1022
|
+
return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
|
|
1023
|
+
const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
1024
|
+
const act = body.get("act");
|
|
1025
|
+
if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
|
|
1026
|
+
return back("problem", "Enter your Toolroll password to change pull requests.");
|
|
1027
|
+
if (act === "on") {
|
|
1028
|
+
const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
|
|
1029
|
+
if (!checked.ok)
|
|
1030
|
+
return back("problem", checked.message);
|
|
1031
|
+
if (checked.plan.githubRepo !== body.get("github") || checked.plan.base !== body.get("base"))
|
|
1032
|
+
return back("problem", "GitHub answered differently from what you saw. Check the details again.");
|
|
1033
|
+
savePublishing(store, checked.plan, who.name, {}, now);
|
|
1034
|
+
return back("said", "Pull requests are on.");
|
|
1035
|
+
}
|
|
1036
|
+
if (act === "settings") {
|
|
1037
|
+
const method = body.get("method") ?? "";
|
|
1038
|
+
if (!MERGE_METHODS.includes(method))
|
|
1039
|
+
return back("problem", "Choose a merge method.");
|
|
1040
|
+
const saved = saveMergeSettings(store, repo, { mergeMethod: method, mergeWhenGreen: body.get("when-green") === "1" }, who.name, now);
|
|
1041
|
+
return saved.ok ? back("said", "Saved.") : back("problem", saved.message);
|
|
1042
|
+
}
|
|
1043
|
+
if (act === "off") {
|
|
1044
|
+
const revoked = store.revokePublicationGrant(repo, who.name, now);
|
|
1045
|
+
if (revoked)
|
|
1046
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "pull requests turned off", outcome: "off", source: "policy" });
|
|
1047
|
+
return back("said", revoked ? "Pull requests are off." : "Pull requests were already off.");
|
|
1048
|
+
}
|
|
1049
|
+
return back("problem", "That change isn't available.");
|
|
1050
|
+
}
|
|
1051
|
+
// Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
|
|
1052
|
+
// the password and lands in the ledger.
|
|
1053
|
+
if (url.pathname === "/settings/checks") {
|
|
1054
|
+
const body = readForm(posted, CONSOLE_FORMS.checks);
|
|
1055
|
+
const repo = body.get("repo") ?? "";
|
|
1056
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
1057
|
+
if (who.via !== "cookie")
|
|
1058
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
1059
|
+
if (!known.includes(repo) || !visible(repo))
|
|
1060
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
1061
|
+
if (who.role !== "approver" || store.isDemo())
|
|
1062
|
+
return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
|
|
1063
|
+
const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
1064
|
+
if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
|
|
1065
|
+
return back("problem", "Enter your Toolroll password to change checks.");
|
|
1066
|
+
const act = body.get("act");
|
|
1067
|
+
// The automatic review switch: the policy log keeps before → after.
|
|
1068
|
+
if (act === "review") {
|
|
1069
|
+
const on = body.get("on") === "1";
|
|
1070
|
+
store.setReviewSwitch(repo, on, who.name, now);
|
|
1071
|
+
return back("said", on ? "Automatic review is on." : "Automatic review is off.");
|
|
1072
|
+
}
|
|
1073
|
+
// Batch checks: off by default; the ledger keeps before → after.
|
|
1074
|
+
if (act === "batch") {
|
|
1075
|
+
const on = body.get("on") === "1";
|
|
1076
|
+
const changed = setProjectBatchChecks(store, repo, on, who.name, now);
|
|
1077
|
+
return back("said", !changed.changed ? "Saved." : on ? "Batch checks are on." : "Batch checks are off.");
|
|
1078
|
+
}
|
|
1079
|
+
if (act === "level") {
|
|
1080
|
+
const level = body.get("level");
|
|
1081
|
+
if (!isCheckLevel(level))
|
|
1082
|
+
return back("problem", "Choose Quick, Full or Off.");
|
|
1083
|
+
const changed = setProjectCheckLevel(store, repo, level, who.name, now);
|
|
1084
|
+
return back("said", changed.changed ? `Checks are ${CHECK_LEVEL_WORDS[level]}.` : "Saved.");
|
|
1085
|
+
}
|
|
1086
|
+
if (act === "quick") {
|
|
1087
|
+
const command = (body.get("command") ?? "").trim();
|
|
1088
|
+
const seconds = Number(body.get("timeout") ?? "180");
|
|
1089
|
+
if (command === "" || command.length > 2000 || hasDisguisedText(command))
|
|
1090
|
+
return back("problem", "Enter the quick command: one line of up to 2000 characters.");
|
|
1091
|
+
if (/([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api_key|authorization|bearer|credential)[A-Za-z0-9_-]*\s*[=:]\s*)(?![$"']?\$)\S+/i.test(command) || /\/\/[^\s/@]+:[^\s/@]+@/.test(command)) {
|
|
1092
|
+
return back("problem", "That command seems to hold a credential. Use an environment variable instead.");
|
|
1093
|
+
}
|
|
1094
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
|
|
1095
|
+
return back("problem", "Choose a time limit from 1 to 3600 seconds.");
|
|
1096
|
+
store.setVerifyCommand({ repo: quickVerifyKey(repo), command, timeoutMs: seconds * 1000, approvedBy: who.name }, now);
|
|
1097
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check approved", outcome: "approved", source: "policy", detail: command.slice(0, 200) });
|
|
1098
|
+
return back("said", "Quick check approved.");
|
|
1099
|
+
}
|
|
1100
|
+
if (act === "quick-clear") {
|
|
1101
|
+
const cleared = store.clearVerifyCommand(quickVerifyKey(repo), who.name, now);
|
|
1102
|
+
if (cleared)
|
|
1103
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check removed", outcome: "removed", source: "policy" });
|
|
1104
|
+
return back("said", cleared ? "Quick check removed. Quick builds run the full check." : "There was no quick check.");
|
|
1105
|
+
}
|
|
1106
|
+
return back("problem", "That change isn't available.");
|
|
1107
|
+
}
|
|
1108
|
+
// Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
|
|
1109
|
+
if (url.pathname === "/settings/backups/now") {
|
|
1110
|
+
const databaseFile = store.databaseFile();
|
|
1111
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
1112
|
+
return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
|
|
1113
|
+
const made = await backupNow(store, databaseFile, "manual", clock);
|
|
1114
|
+
return redirect(response, made.ok ? `/settings/backups?said=${encodeURIComponent("Backed up.")}` : `/settings/backups?problem=${encodeURIComponent(`The backup failed: ${made.error}`)}`);
|
|
1115
|
+
}
|
|
1116
|
+
// Sprint 8: change the backup schedule. A step-up; the ledger keeps before → after.
|
|
1117
|
+
if (url.pathname === "/settings/backups") {
|
|
1118
|
+
const body = readForm(posted, CONSOLE_FORMS.backups);
|
|
1119
|
+
const databaseFile = store.databaseFile();
|
|
1120
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
1121
|
+
return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
|
|
1122
|
+
const back = (key, words) => redirect(response, `/settings/backups?${key}=${encodeURIComponent(words)}`);
|
|
1123
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
1124
|
+
return back("problem", "Enter your Toolroll password to change backups.");
|
|
1125
|
+
const every = body.get("every") ?? "";
|
|
1126
|
+
const hours = Number(every);
|
|
1127
|
+
if (every !== "off" && !BACKUP_EVERY_HOURS.includes(hours))
|
|
1128
|
+
return back("problem", "Choose how often to back up.");
|
|
1129
|
+
const keep = Number(body.get("keep") ?? "");
|
|
1130
|
+
if (!Number.isSafeInteger(keep) || keep < 1 || keep > MAX_KEEP)
|
|
1131
|
+
return back("problem", `Keep between 1 and ${MAX_KEEP} backups.`);
|
|
1132
|
+
const typed = (body.get("folder") ?? "").trim();
|
|
1133
|
+
const folder = typed === "" || typed === defaultBackupFolder(databaseFile) ? null : typed;
|
|
1134
|
+
if (folder !== null) {
|
|
1135
|
+
const problem = checkBackupFolder(folder, [...new Set([...managedRepos(), ...store.knownRepos(), ...(options.poolRoot === undefined ? [] : [options.poolRoot])])]);
|
|
1136
|
+
if (problem !== null)
|
|
1137
|
+
return back("problem", problem);
|
|
1138
|
+
}
|
|
1139
|
+
const before = store.backupSettings();
|
|
1140
|
+
store.setBackupSettings({ enabled: every !== "off", everyHours: every === "off" ? before.everyHours : hours, keep, folder }, who.name, now);
|
|
1141
|
+
return back("said", every === "off" ? "Saved. Scheduled backups are off." : "Saved.");
|
|
1142
|
+
}
|
|
1143
|
+
// v105: download everything as a .zip. An instance operator, with a step-up; the ledger records it.
|
|
1144
|
+
if (url.pathname === "/settings/data") {
|
|
1145
|
+
const body = readForm(posted, CONSOLE_FORMS.data);
|
|
1146
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
1147
|
+
return refuse(response, who, 403, "An instance operator exports data.", "/settings");
|
|
1148
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok) {
|
|
1149
|
+
return redirect(response, `/settings/data?problem=${encodeURIComponent("Enter your Toolroll password to download the export.")}`);
|
|
1150
|
+
}
|
|
1151
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "everything exported", outcome: "exported", source: "access", detail: "downloaded as a .zip" });
|
|
1152
|
+
const exported = buildExport(store, { who: who.name, now, evidenceRoot, configDir: options.configDir ?? null });
|
|
1153
|
+
const zip = exportZip(exported);
|
|
1154
|
+
response.writeHead(200, { "content-type": "application/zip", "content-disposition": `attachment; filename="${exported.root}.zip"`, "content-length": String(zip.length),
|
|
1155
|
+
"cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
1156
|
+
return void response.end(zip);
|
|
1157
|
+
}
|
|
1158
|
+
// Settings → Flows → Switch on: a starter flow's zones and trigger, made together.
|
|
1159
|
+
if (url.pathname === "/settings/flows/on") {
|
|
1160
|
+
const body = readForm(posted, CONSOLE_FORMS.flowsOn);
|
|
1161
|
+
const repo = body.get("repo") ?? "";
|
|
1162
|
+
const back = (key, words) => redirect(response, `/settings/flows?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
1163
|
+
const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])];
|
|
1164
|
+
if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
|
|
1165
|
+
return refuse(response, who, 403, "An approver switches starter flows on.", "/settings/flows");
|
|
1166
|
+
if (!known.includes(repo) || !visible(repo))
|
|
1167
|
+
return refuse(response, who, 404, "No such project.", "/settings/flows");
|
|
1168
|
+
const starter = starterOf(body.get("starter") ?? "");
|
|
1169
|
+
if (starter === null)
|
|
1170
|
+
return back("problem", "Choose a starter flow.");
|
|
1171
|
+
const switched = switchOnStarter(store, starter, repo, who.name, clock(), options.configDir ?? null);
|
|
1172
|
+
return switched.ok ? back("said", switched.said) : back("problem", switched.said);
|
|
1173
|
+
}
|
|
1174
|
+
// Settings → Integrations → Send test: one harmless read-only check of one integration, now.
|
|
1175
|
+
if (url.pathname === "/settings/integrations/test") {
|
|
1176
|
+
const body = readForm(posted, CONSOLE_FORMS.integrationsTest);
|
|
1177
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
1178
|
+
return refuse(response, who, 403, "An installation approver tests integrations.", "/settings");
|
|
1179
|
+
const key = body.get("key") ?? "";
|
|
1180
|
+
const back = (name, words) => redirect(response, `/settings/integrations?${name}=${encodeURIComponent(words)}`);
|
|
1181
|
+
const checked = (await integrations.check([key])).find(one => one.key === key);
|
|
1182
|
+
if (checked === undefined)
|
|
1183
|
+
return back("problem", "That integration isn't here any more.");
|
|
1184
|
+
return checked.state === "connected" ? back("said", `${checked.name} works${checked.account === null ? "" : ` (${checked.account})`}.`)
|
|
1185
|
+
: back("problem", `${checked.name}: ${checked.action.kind === "fix" ? checked.action.words : "not set up."}`);
|
|
1186
|
+
}
|
|
1187
|
+
// v104: change where monitoring goes. A step-up; the ledger keeps before → after (addresses, never secrets).
|
|
1188
|
+
if (url.pathname === "/settings/monitoring") {
|
|
1189
|
+
const body = readForm(posted, CONSOLE_FORMS.monitoring);
|
|
1190
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
1191
|
+
return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
|
|
1192
|
+
const back = (key, words) => redirect(response, `/settings/monitoring?${key}=${encodeURIComponent(words)}`);
|
|
1193
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
1194
|
+
return back("problem", "Enter your Toolroll password to change monitoring.");
|
|
1195
|
+
const before = readMonitoring(options.configDir);
|
|
1196
|
+
// Never into Toolroll's own folder, a project or the build checkouts: an agent could read the stream there.
|
|
1197
|
+
const saved = saveMonitoring(options.configDir, { webhook: body.get("webhook") ?? "", folder: body.get("folder") ?? "", tracesEndpoint: body.get("traces") ?? "",
|
|
1198
|
+
headerName: body.get("header-name") ?? "", headerValue: body.get("header-value") ?? "", rotate: body.get("rotate") === "1" }, before, [...new Set([...managedRepos(), ...store.knownRepos(), ...(options.poolRoot === undefined ? [] : [options.poolRoot])])]);
|
|
1199
|
+
if (!saved.ok)
|
|
1200
|
+
return back("problem", saved.said);
|
|
1201
|
+
const after = saved.settings;
|
|
1202
|
+
// A destination that's new or points elsewhere starts over on the monitoring loop's next pass.
|
|
1203
|
+
const change = monitoringChange(before, after, saved.secret !== null);
|
|
1204
|
+
if (change !== null) {
|
|
1205
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "monitoring changed", outcome: "changed", source: "policy", detail: change });
|
|
1206
|
+
}
|
|
1207
|
+
if (saved.secret !== null && after.webhook !== null) {
|
|
1208
|
+
return sendScreen(response, 200, screen("Signing secret", signingSecretHtml(after.webhook.url, saved.secret), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
|
|
1209
|
+
}
|
|
1210
|
+
return back("said", "Saved. Deliveries start within a few seconds.");
|
|
1211
|
+
}
|
|
1212
|
+
if (url.pathname === "/settings/tools/connect") {
|
|
1213
|
+
const body = readForm(posted, CONSOLE_FORMS.toolsConnect);
|
|
1214
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1215
|
+
return refuse(response, who, 403, "Sign in as an approver to connect tools.", "/settings/tools");
|
|
1216
|
+
const repo = body.get("repo") ?? "";
|
|
1217
|
+
const reachable = (one) => visible(one) && consoleProjects().includes(one);
|
|
1218
|
+
if (!reachable(repo))
|
|
1219
|
+
return refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
1220
|
+
// "Also connect to …": a service connected on the shown project, signed in again for another project.
|
|
1221
|
+
const also = body.get("also");
|
|
1222
|
+
const split = also === null ? -1 : also.indexOf(":");
|
|
1223
|
+
const service = oneClickOf(also === null ? body.get("service") ?? "" : split > 0 ? also.slice(0, split) : "");
|
|
1224
|
+
// An app on this computer (Figma's desktop app) connects here, never "also" elsewhere.
|
|
1225
|
+
const local = service === null && also === null ? localConnectOf(body.get("service") ?? "") : null;
|
|
1226
|
+
const chosen = service ?? local;
|
|
1227
|
+
const target = also === null ? repo : split > 0 ? also.slice(split + 1) : "";
|
|
1228
|
+
// From a gallery template that uses this tool: it comes back to that template's page, for this project.
|
|
1229
|
+
const gallery = also === null && chosen !== null ? galleryTemplateOf(body.get("template") ?? "") : null;
|
|
1230
|
+
const template = gallery !== null && (gallery.tools ?? []).includes(chosen.id) ? gallery.id : null;
|
|
1231
|
+
const kit = template === null ? kitOf(body.get("kit") ?? "")?.id ?? null : null;
|
|
1232
|
+
// A Connect that fails comes back to its own button, where the reason is said (no #fragment: one stops the alert's
|
|
1233
|
+
// autofocus); an "Also connect to" refusal goes back to the Connect card.
|
|
1234
|
+
const back = (words, page = repo) => template !== null ? redirect(response, `/flows/new/${template}?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent(words)}`)
|
|
1235
|
+
: redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}${kit === null ? "" : `&kit=${kit}`}${chosen === null || also !== null ? "" : `&connect=${chosen.id}`}&problem=${encodeURIComponent(words)}${also === null ? "" : "#connect"}`);
|
|
1236
|
+
// The page posts the project it showed; a post from a page showing another project is stale.
|
|
1237
|
+
const shown = body.get("shown") ?? "";
|
|
1238
|
+
if (shown !== repo)
|
|
1239
|
+
return back("The project changed; connect again.", reachable(shown) ? shown : repo);
|
|
1240
|
+
if (chosen === null)
|
|
1241
|
+
return back("Choose a service to connect.");
|
|
1242
|
+
if (also !== null && service !== null) {
|
|
1243
|
+
if (!reachable(target))
|
|
1244
|
+
return refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
1245
|
+
if (connectionsOf(store, repo).find(one => one.id === service.id)?.state !== "connected")
|
|
1246
|
+
return back(`${service.label} isn't connected to ${projectName(repo)}.`);
|
|
1247
|
+
if (connectionsOf(store, target).find(one => one.id === service.id)?.state !== "open")
|
|
1248
|
+
return back(`${projectName(target)} already has ${service.label}.`);
|
|
1249
|
+
}
|
|
1250
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
1251
|
+
return back("Enter your Toolroll password to connect a tool.");
|
|
1252
|
+
// An app on this computer: added like a common tool and tested; one that can't be reached isn't kept.
|
|
1253
|
+
if (local !== null) {
|
|
1254
|
+
const had = projectToolsOf(store, repo).find(one => one.name === local.id);
|
|
1255
|
+
if (had !== undefined && localAppOf(had.spec) === null)
|
|
1256
|
+
return back(`This project already has a tool called ${local.id}, set up another way. Remove it on this page to connect ${local.label}.`);
|
|
1257
|
+
if (had === undefined) {
|
|
1258
|
+
const { label: _label, ...spec } = catalogTool(local.id);
|
|
1259
|
+
const added = addToolTo(store, repo, spec, `${local.label}, connected on this computer`, who.name, clock(), { home: toolHome });
|
|
1260
|
+
if (!added.ok)
|
|
1261
|
+
return back(added.message);
|
|
1262
|
+
}
|
|
1263
|
+
const tested = await testToolOf(store, repo, local.id, clock(), { home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
|
|
1264
|
+
const connected = `${local.label} is connected: ${tested?.tools.length ?? 0} action${tested?.tools.length === 1 ? "" : "s"}.`;
|
|
1265
|
+
if (tested?.ok)
|
|
1266
|
+
return redirect(response, template !== null ? `/flows/new/${template}?repo=${encodeURIComponent(repo)}&said=${encodeURIComponent(connected)}`
|
|
1267
|
+
: `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}&said=${encodeURIComponent(connected)}#tool-${local.id}`);
|
|
1268
|
+
if (had === undefined)
|
|
1269
|
+
removeToolFrom(store, repo, local.id, who.name, clock(), toolHome);
|
|
1270
|
+
return back(tested?.problem ?? `${local.label} didn't answer.`);
|
|
1271
|
+
}
|
|
1272
|
+
if (service === null)
|
|
1273
|
+
return back("Choose a service to connect.");
|
|
1274
|
+
const origin = consoleOrigin(request.headers.host);
|
|
1275
|
+
if (origin === null)
|
|
1276
|
+
return back("Connect tools from this computer (localhost) or from your https address.");
|
|
1277
|
+
// Reconnect read-only (from a connected service's Tools entry): the same sign-in, asking only to read.
|
|
1278
|
+
const readOnly = also === null && body.get("access") === "read";
|
|
1279
|
+
const started = await startConnect({ service: service.id, repo: target, by: who.name, origin, kit: also === null ? kit : null, template, readOnly }, options.connectFetch ?? fetch);
|
|
1280
|
+
if (!started.ok)
|
|
1281
|
+
return back(started.said);
|
|
1282
|
+
for (const [key, visit] of connectVisits)
|
|
1283
|
+
if (visit.expires < Date.now())
|
|
1284
|
+
connectVisits.delete(key);
|
|
1285
|
+
connectVisits.set(started.state, started.visit);
|
|
1286
|
+
return goOutside(response, started.go, `Going to ${service.label} to sign in…`, { state: started.state, path: CONNECT_CALLBACK, secure: origin.startsWith("https:") });
|
|
1287
|
+
}
|
|
1288
|
+
if (url.pathname === "/settings/tools/change") {
|
|
1289
|
+
const body = readForm(posted, CONSOLE_FORMS.toolsChange);
|
|
1290
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1291
|
+
return refuse(response, who, 403, "Sign in as an approver to manage tools.", "/settings/tools");
|
|
1292
|
+
const repo = body.get("repo") ?? "";
|
|
1293
|
+
if (!visible(repo) || !consoleProjects().includes(repo))
|
|
1294
|
+
return refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
1295
|
+
const back = (key, words, anchor = "") => redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}${anchor}`);
|
|
1296
|
+
// As for Connect: a post from a page that showed another project is refused, never applied here.
|
|
1297
|
+
const shown = body.get("shown") ?? "";
|
|
1298
|
+
if (shown !== repo) {
|
|
1299
|
+
const page = visible(shown) && consoleProjects().includes(shown) ? shown : repo;
|
|
1300
|
+
return redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent("The project changed; try again.")}`);
|
|
1301
|
+
}
|
|
1302
|
+
const action = body.get("action") ?? "";
|
|
1303
|
+
const name = body.get("name") ?? "";
|
|
1304
|
+
// Anything that adds what a build can run or reach, or a secret, needs the password.
|
|
1305
|
+
if (["add-catalog", "add-custom", "import", "secret"].includes(action) && !authenticateApprover(who, body.get("password") ?? "").ok) {
|
|
1306
|
+
return back("problem", "Enter your Toolroll password to change this project's tools.");
|
|
1307
|
+
}
|
|
1308
|
+
const now = clock();
|
|
1309
|
+
const finish = (added, label) => {
|
|
1310
|
+
if (!added.ok)
|
|
1311
|
+
return back("problem", added.message);
|
|
1312
|
+
const needs = added.spec.secrets.filter(one => !one.optional).map(one => one.name);
|
|
1313
|
+
return back("said", `Added ${label}. ${needs.length > 0 ? `Set ${needs.join(" and ")} below to finish.` : "Test it to check it starts."} Work you approve from now on can use it.`, `#tool-${added.spec.name}`);
|
|
1314
|
+
};
|
|
1315
|
+
try {
|
|
1316
|
+
if (action === "add-catalog") {
|
|
1317
|
+
const chosen = catalogTool(body.get("catalog") ?? "");
|
|
1318
|
+
if (chosen === null)
|
|
1319
|
+
return back("problem", "Choose a tool from the list.");
|
|
1320
|
+
const { label: _label, ...spec } = chosen;
|
|
1321
|
+
return finish(addToolTo(store, repo, spec, "the common tools list", who.name, now, { home: toolHome }), chosen.label);
|
|
1322
|
+
}
|
|
1323
|
+
if (action === "add-custom") {
|
|
1324
|
+
const transport = body.get("transport") === "http" ? "http" : "stdio";
|
|
1325
|
+
const target = body.get("target") ?? "";
|
|
1326
|
+
const secrets = (body.get("secrets") ?? "").split(",").map(one => one.trim()).filter(Boolean);
|
|
1327
|
+
const parts = transport === "stdio" ? splitCommandLine(target) : [];
|
|
1328
|
+
const spec = { name, transport, command: parts[0] ?? null, args: parts.slice(1), url: transport === "http" ? target : null,
|
|
1329
|
+
secrets: secrets.map(one => ({ name: one, optional: false })), bearer: transport === "http" ? secrets[0] ?? null : null, headerSecrets: {}, about: "" };
|
|
1330
|
+
return finish(addToolTo(store, repo, spec, "added by hand", who.name, now, { home: toolHome }), name);
|
|
1331
|
+
}
|
|
1332
|
+
if (action === "import") {
|
|
1333
|
+
const wanted = body.getAll("import");
|
|
1334
|
+
if (wanted.length === 0)
|
|
1335
|
+
return back("problem", "Choose at least one tool to add.");
|
|
1336
|
+
const found = discoverTools(repo, await codexServers(repo), toolHome);
|
|
1337
|
+
const added = [], problems = [];
|
|
1338
|
+
for (const one of wanted) {
|
|
1339
|
+
const match = found.find(tool => tool.spec.name === one);
|
|
1340
|
+
if (match === undefined) {
|
|
1341
|
+
problems.push(`${one} is no longer on this computer`);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
const result = addToolTo(store, repo, match.spec, match.source, who.name, now, { values: match.values, home: toolHome });
|
|
1345
|
+
if (result.ok)
|
|
1346
|
+
added.push(one);
|
|
1347
|
+
else
|
|
1348
|
+
problems.push(result.message);
|
|
1349
|
+
}
|
|
1350
|
+
return problems.length > 0 ? back("problem", `${added.length > 0 ? `Added ${added.join(", ")}. ` : ""}${problems.join(" ")}`) : back("said", `Added ${added.join(", ")}. Test each to check it starts. Work you approve from now on can use them.`);
|
|
1351
|
+
}
|
|
1352
|
+
const tool = projectToolsOf(store, repo).find(one => one.name === name);
|
|
1353
|
+
if (tool === undefined)
|
|
1354
|
+
return back("problem", "That tool is no longer on this project.");
|
|
1355
|
+
if (action === "remove") {
|
|
1356
|
+
removeToolFrom(store, repo, name, who.name, now, toolHome);
|
|
1357
|
+
return back("said", `Removed ${name}. No build uses it from now on.`);
|
|
1358
|
+
}
|
|
1359
|
+
if (action === "test") {
|
|
1360
|
+
const test = await testToolOf(store, repo, name, now, { omitEnv: ALL_CREDENTIAL_ENV, home: toolHome });
|
|
1361
|
+
return test === null || !test.ok ? back("problem", `${name}: ${test?.problem ?? "the test did not run."}`, `#tool-${name}`) : back("said", `${name} works: ${test.tools.length} tool${test.tools.length === 1 ? "" : "s"}.`, `#tool-${name}`);
|
|
1362
|
+
}
|
|
1363
|
+
if (action === "secret") {
|
|
1364
|
+
const secret = body.get("secret") ?? "";
|
|
1365
|
+
if (!tool.spec.secrets.some(one => one.name === secret))
|
|
1366
|
+
return back("problem", "Choose one of this tool's secrets.");
|
|
1367
|
+
setToolSecret(repo, name, secret, body.get("value") ?? "", toolHome);
|
|
1368
|
+
return back("said", `Saved ${secret} for ${name}. It is never shown again.`, `#tool-${name}`);
|
|
1369
|
+
}
|
|
1370
|
+
return back("problem", "Choose a supported tools action.");
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
return back("problem", error instanceof Error ? error.message : "Tools could not be changed. Try again.");
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (url.pathname === "/settings/appearance") {
|
|
1377
|
+
const body = readForm(posted, CONSOLE_FORMS.appearance);
|
|
1378
|
+
// A per-browser preference, not a shared setting: it lives in this
|
|
1379
|
+
// person's own cookie and never in the database.
|
|
1380
|
+
const accentField = body.get("accent");
|
|
1381
|
+
if (accentField !== null) {
|
|
1382
|
+
const accent = normalHex(accentField);
|
|
1383
|
+
if (accent === null)
|
|
1384
|
+
return refuse(response, who, 400, "Choose a colour as six hex digits, like #6e56cf.", "/settings");
|
|
1385
|
+
response.setHeader("Set-Cookie", accent === DEFAULT_ACCENT
|
|
1386
|
+
? "so-accent=; SameSite=Lax; Path=/; Max-Age=0"
|
|
1387
|
+
: `so-accent=${accent.slice(1)}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
1388
|
+
// The picker saves in the background as the colour settles; a plain form post goes back.
|
|
1389
|
+
if (body.get("quiet") === "1") {
|
|
1390
|
+
response.writeHead(204);
|
|
1391
|
+
response.end();
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
return redirect(response, safeReturn(body.get("return") ?? "/settings"));
|
|
1395
|
+
}
|
|
1396
|
+
const theme = body.get("theme") ?? "";
|
|
1397
|
+
if (!["system", "light", "dark"].includes(theme))
|
|
1398
|
+
return refuse(response, who, 400, "Choose System, Light or Dark.", "/settings");
|
|
1399
|
+
const back = safeReturn(body.get("return") ?? "/settings");
|
|
1400
|
+
response.setHeader("Set-Cookie", theme === "system"
|
|
1401
|
+
? "so-theme=; SameSite=Lax; Path=/; Max-Age=0"
|
|
1402
|
+
: `so-theme=${theme}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
1403
|
+
return redirect(response, back);
|
|
1404
|
+
}
|
|
1405
|
+
if (url.pathname.startsWith("/settings/models/")) {
|
|
1406
|
+
const body = readForm(posted, CONSOLE_FORMS.models);
|
|
1407
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1408
|
+
return refuse(response, who, 403, "Sign in as an approver to change models.", "/settings/models");
|
|
1409
|
+
const back = (said, bad = false) => redirect(response, `/settings/models?${bad ? "problem" : "said"}=${encodeURIComponent(said)}`);
|
|
1410
|
+
if (url.pathname === "/settings/models/check") {
|
|
1411
|
+
const checked = await checkModels(store, now, modelSeams);
|
|
1412
|
+
return back(checked.ok ? `Checked. ${checked.added.filter(one => one.source !== "openrouter").length || "No"} new model${checked.added.filter(one => one.source !== "openrouter").length === 1 ? "" : "s"}.` : checked.problem ?? "The check did not finish.", !checked.ok);
|
|
1413
|
+
}
|
|
1414
|
+
if (url.pathname === "/settings/models/watch") {
|
|
1415
|
+
const enabled = body.get("enabled") === "1";
|
|
1416
|
+
setWatch(store, enabled, who.name, now);
|
|
1417
|
+
return back(enabled ? "Automatic checks are on." : "Automatic checks are off.");
|
|
1418
|
+
}
|
|
1419
|
+
if (url.pathname === "/settings/models/update") {
|
|
1420
|
+
const tool = body.get("tool") ?? "";
|
|
1421
|
+
if (!(tool in RUNTIME_TOOLS))
|
|
1422
|
+
return back("Choose Claude Code, Codex or Gemini CLI.", true);
|
|
1423
|
+
const updated = await updateRuntime(store, tool, who.name, now, modelSeams);
|
|
1424
|
+
return back(updated.message, !updated.ok);
|
|
1425
|
+
}
|
|
1426
|
+
if (url.pathname === "/settings/models/agent") {
|
|
1427
|
+
const phase = body.get("phase") ?? "", agent = body.get("agent") ?? "";
|
|
1428
|
+
if (!["plan", "build", "review", "repair"].includes(phase))
|
|
1429
|
+
return back("Choose a role.", true);
|
|
1430
|
+
const role = ROLE_TITLES[phase];
|
|
1431
|
+
if (agent === "inherit") {
|
|
1432
|
+
if (phase !== "repair")
|
|
1433
|
+
return back("Choose a model.", true);
|
|
1434
|
+
store.clearPhaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1435
|
+
return back("Repairs now use the builder's agent.");
|
|
1436
|
+
}
|
|
1437
|
+
const split = agent.indexOf("|");
|
|
1438
|
+
const provider = split < 0 ? "" : agent.slice(0, split), model = split < 0 ? "" : agent.slice(split + 1);
|
|
1439
|
+
if (!isProviderId(provider) || !validModelId(model))
|
|
1440
|
+
return back("Choose a model from the list.", true);
|
|
1441
|
+
const valid = validateSpec({ provider, model });
|
|
1442
|
+
if (!valid.ok)
|
|
1443
|
+
return back(valid.problem, true);
|
|
1444
|
+
if (phase === "review" && provider === "gemini")
|
|
1445
|
+
return back("Gemini cannot review yet. Choose Claude or Codex.", true);
|
|
1446
|
+
const build = store.phaseConfig(INSTALLATION_SCOPE, "build");
|
|
1447
|
+
if (phase === "repair" && build !== null && build.provider !== provider)
|
|
1448
|
+
return back(`Repairs must use the builder's provider (${build.provider}).`, true);
|
|
1449
|
+
store.setPhaseConfig(INSTALLATION_SCOPE, phase, provider, model, who.name, now);
|
|
1450
|
+
const repair = store.phaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1451
|
+
let extra = "";
|
|
1452
|
+
if (phase === "build" && repair !== null && repair.provider !== provider) {
|
|
1453
|
+
store.clearPhaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1454
|
+
extra = " Repairs now follow the builder.";
|
|
1455
|
+
}
|
|
1456
|
+
return back(`${role} now uses ${modelWords(store, provider, model)} for new tasks.${extra}`);
|
|
1457
|
+
}
|
|
1458
|
+
return refuse(response, who, 404, "No such models action.", "/settings/models");
|
|
1459
|
+
}
|
|
1460
|
+
if (url.pathname === "/settings/knowledge/refresh") {
|
|
1461
|
+
const body = readForm(posted, CONSOLE_FORMS.knowledgeRefresh);
|
|
1462
|
+
if (who.via !== 'cookie' || who.role !== 'approver')
|
|
1463
|
+
return refuse(response, who, 403, 'Sign in as an approver to refresh project context.', '/settings/knowledge');
|
|
1464
|
+
const repo = body.get('repo') ?? '';
|
|
1465
|
+
if (!visible(repo) || !store.accountCanAccess(who.name, repo) || !consoleProjects().includes(repo))
|
|
1466
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
|
|
1467
|
+
const refreshed = await repositoryContext({ repo, query: '', refresh: true, cacheRoot: join(dirname(evidenceRoot), 'repository-context') });
|
|
1468
|
+
return sendScreen(response, refreshed.index.status === 'ready' ? 200 : 503, screen('Project context', `<h1>Project context</h1><p>${refreshed.index.status === 'ready' ? 'Code index refreshed.' : 'The index is unavailable. Source search still works.'}</p><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Open knowledge</a>`, { chrome: chromeFor(repo, 'settings') }));
|
|
1469
|
+
}
|
|
1470
|
+
if (url.pathname === "/settings/knowledge/proposal") {
|
|
1471
|
+
const body = readForm(posted, CONSOLE_FORMS.knowledgeProposal);
|
|
1472
|
+
if (who.via !== 'cookie' || who.role !== 'approver')
|
|
1473
|
+
return refuse(response, who, 403, 'Sign in as an approver to decide memory proposals.', '/settings/knowledge');
|
|
1474
|
+
const repo = body.get('repo') ?? '', decision = body.get('decision') ?? '';
|
|
1475
|
+
if (!visible(repo) || !consoleProjects().includes(repo))
|
|
1476
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
|
|
1477
|
+
if (!['accept', 'reject'].includes(decision) || !/^[0-9]{1,12}$/.test(body.get('proposal') ?? ''))
|
|
1478
|
+
return refuse(response, who, 400, 'Choose accept or reject for one proposal.', '/settings/knowledge');
|
|
1479
|
+
try {
|
|
1480
|
+
decideProposal(store, { repo, actor: who.name, id: Number(body.get('proposal')), decision: decision }, now);
|
|
1481
|
+
}
|
|
1482
|
+
catch (error) {
|
|
1483
|
+
return sendScreen(response, 409, screen('Knowledge', `<h1>Knowledge</h1><p role="alert">${escape(error instanceof Error ? error.message : 'That proposal could not be decided.')}</p><p><a href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Back to knowledge</a></p>`, { chrome: chromeFor(repo, 'settings') }));
|
|
1484
|
+
}
|
|
1485
|
+
return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
|
|
1486
|
+
}
|
|
1487
|
+
if (url.pathname === "/settings/knowledge/decision") {
|
|
1488
|
+
const body = readForm(posted, CONSOLE_FORMS.knowledgeDecision);
|
|
1489
|
+
if (who.via !== 'cookie' || who.role !== 'approver')
|
|
1490
|
+
return refuse(response, who, 403, 'Sign in as an approver to change project decisions.', '/settings/knowledge');
|
|
1491
|
+
const repo = body.get('repo') ?? '', action = body.get('action') ?? '';
|
|
1492
|
+
if (!visible(repo) || !consoleProjects().includes(repo))
|
|
1493
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
|
|
1494
|
+
if (['repo', 'action', 'claim', 'why', 'decision', 'reason'].some(k => body.getAll(k).length > 1) || !['record', 'retire'].includes(action))
|
|
1495
|
+
return refuse(response, who, 400, 'Choose record or retire.', '/settings/knowledge');
|
|
1496
|
+
try {
|
|
1497
|
+
if (action === 'record')
|
|
1498
|
+
recordDecision(store, { repo, actor: who.name, draft: { claim: body.get('claim') ?? '', why: body.get('why') ?? '', sourceKind: 'manual' } }, now);
|
|
1499
|
+
else
|
|
1500
|
+
retireDecision(store, { repo, actor: who.name, id: Number(body.get('decision')), reason: body.get('reason') ?? '' }, now);
|
|
1501
|
+
}
|
|
1502
|
+
catch (error) {
|
|
1503
|
+
const draft = { claim: body.get('claim') ?? '', why: body.get('why') ?? '' };
|
|
1504
|
+
let content = '';
|
|
1505
|
+
try {
|
|
1506
|
+
content = decisionsHtml(repo, listDecisions(store, repo, who.name, { limit: 50 }), who.session.csrf, true, draft, error instanceof Error ? error.message : 'Save failed. Your draft is below.');
|
|
1507
|
+
}
|
|
1508
|
+
catch {
|
|
1509
|
+
content = `<p role="alert">Project decisions are unavailable. Your unsaved draft is below.</p><pre class="knowledge">${escape(JSON.stringify(draft, null, 2))}</pre>`;
|
|
1510
|
+
}
|
|
1511
|
+
return sendScreen(response, 409, screen('Knowledge', `<h1>Knowledge</h1>${content}`, { chrome: chromeFor(repo, 'settings') }));
|
|
1512
|
+
}
|
|
1513
|
+
return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
|
|
1514
|
+
}
|
|
1515
|
+
if (url.pathname === "/settings/knowledge/change") {
|
|
1516
|
+
const body = readForm(posted, CONSOLE_FORMS.knowledgeChange);
|
|
1517
|
+
if (who.via !== 'cookie' || who.role !== 'approver')
|
|
1518
|
+
return refuse(response, who, 403, 'Sign in as an approver to change project knowledge.', '/settings/knowledge');
|
|
1519
|
+
const repo = body.get('repo') ?? '', action = body.get('action') ?? '';
|
|
1520
|
+
if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
|
|
1521
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
|
|
1522
|
+
if (['repo', 'action', 'identity', 'revision', 'instructions', 'title', 'content', 'path', 'id', 'restore'].some(k => body.getAll(k).length > 1) || !['instructions', 'save', 'remove', 'restore', 'apply'].includes(action) || !/^[0-9]+$/.test(body.get('revision') ?? ''))
|
|
1523
|
+
return refuse(response, who, 400, 'Invalid knowledge form.', '/settings/knowledge');
|
|
1524
|
+
const draft = Object.fromEntries(['instructions', 'title', 'content', 'path', 'id'].filter(k => body.has(k)).map(k => [k, body.get(k)]));
|
|
1525
|
+
try {
|
|
1526
|
+
if (action === 'apply')
|
|
1527
|
+
applySavedKnowledge(store, repo, who.name, clock());
|
|
1528
|
+
else
|
|
1529
|
+
changeKnowledge(store, { repo, actor: who.name, identity: body.get('identity') ?? '', revision: Number(body.get('revision')), action: action, draft, restore: Number(body.get('restore')) }, clock());
|
|
1530
|
+
}
|
|
1531
|
+
catch (error) {
|
|
1532
|
+
let content = `<p role="alert">Project knowledge is unavailable. Your unsaved draft is below.</p><pre class="knowledge">${escape(JSON.stringify(draft, null, 2))}</pre>`;
|
|
1533
|
+
try {
|
|
1534
|
+
content = knowledgeHtml(knowledgeView(store, repo, who.name), who.session.csrf, true, draft, error instanceof Error ? error.message : 'Save failed. Your draft is below.');
|
|
1535
|
+
}
|
|
1536
|
+
catch { /* never render unverified saved context */ }
|
|
1537
|
+
return sendScreen(response, 409, screen('Knowledge', `<h1>Knowledge</h1>${content}`, { chrome: chromeFor(repo, 'settings') }));
|
|
1538
|
+
}
|
|
1539
|
+
return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
|
|
1540
|
+
}
|
|
1541
|
+
if (url.pathname === "/settings/learning/change") {
|
|
1542
|
+
const body = readForm(posted, CONSOLE_FORMS.learningChange);
|
|
1543
|
+
if (who.via !== "cookie")
|
|
1544
|
+
return refuse(response, who, 403, "Sign in to change learning.", "/settings/learning");
|
|
1545
|
+
const repo = body.get("repo") ?? "", action = body.get("action") ?? "";
|
|
1546
|
+
if (!visible(repo) || !consoleProjects().includes(repo))
|
|
1547
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
|
|
1548
|
+
if (["repo", "action", "identity", "revision", "lesson", "version", "sha"].some(k => body.getAll(k).length > 1) || !["adopt", "disable", "reset", "enable", "pause"].includes(action) || !/^[0-9]+$/.test(body.get("revision") ?? ""))
|
|
1549
|
+
return refuse(response, who, 400, "Invalid learning form.", "/settings/learning");
|
|
1550
|
+
try {
|
|
1551
|
+
changeLearning(store, evidenceRoot, { repo, actor: who.name, identity: body.get("identity") ?? "", revision: Number(body.get("revision")), action: action, lesson: Number(body.get("lesson")), version: Number(body.get("version")), sha: body.get("sha") ?? "" }, clock());
|
|
1552
|
+
}
|
|
1553
|
+
catch (error) {
|
|
1554
|
+
return sendScreen(response, 409, screen("Learning", `<section class="learning"><h1>Learning</h1><p class="problem" role="alert">${escape(error instanceof Error ? error.message : "Learning could not be changed. Reload to retry.")}</p><a class="button-link" href="/settings/learning?repo=${encodeURIComponent(repo)}">Reload Learning</a></section>`));
|
|
1555
|
+
}
|
|
1556
|
+
return redirect(response, `/settings/learning?repo=${encodeURIComponent(repo)}`);
|
|
1557
|
+
}
|
|
1558
|
+
if (["/control/setup-preview", "/control/setup-approve", "/control/instructions-preview", "/control/instructions-approve"].includes(url.pathname)) {
|
|
1559
|
+
const body = readForm(posted, CONSOLE_FORMS.projectSetup);
|
|
1560
|
+
if (who.via !== "cookie")
|
|
1561
|
+
return refuse(response, who, 403, REMOTE_MESSAGES["step-up"]);
|
|
1562
|
+
const project = projectOf(who, request);
|
|
1563
|
+
if (project == null || body.get("repo") !== project || !visible(project))
|
|
1564
|
+
return refuse(response, who, 409, "The selected project changed. Open project setup again.", "/control");
|
|
1565
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
1566
|
+
if (url.pathname.includes("instructions")) {
|
|
1567
|
+
const preview = previewProjectInstructions(project);
|
|
1568
|
+
if (!preview.ok)
|
|
1569
|
+
return refuse(response, who, 400, preview.message, "/control");
|
|
1570
|
+
const nonceKey = `project-instructions:${project}`;
|
|
1571
|
+
if (url.pathname.endsWith("preview")) {
|
|
1572
|
+
const nonce = mintApprovalNonce(who.name, nonceKey, preview.fingerprint);
|
|
1573
|
+
return sendScreen(response, 200, screen("Review agent instructions", `<h1>Review agent instructions</h1><p>${escape(preview.plan.skillPath)}</p><pre class="recap">${escape(preview.content)}</pre><form method="post" action="/control/instructions-approve">${hiddenFields({ csrf, repo: project, nonce, fingerprint: preview.fingerprint })}<label>Your password<input type="password" name="token" autocomplete="current-password" required></label><button>Add these instructions</button></form>`, { chrome: chromeFor(project, "settings") }));
|
|
1574
|
+
}
|
|
1575
|
+
if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
|
|
1576
|
+
return refuse(response, who, 409, "This instruction preview expired. Review it again.", "/control");
|
|
1577
|
+
if (!authenticateApprover(who, body.get("token") ?? "").ok)
|
|
1578
|
+
return refuse(response, who, 403, "Your operator credential is required.", "/control");
|
|
1579
|
+
const added = addProjectInstructions(project, body.get("fingerprint") ?? "");
|
|
1580
|
+
if (!added.ok)
|
|
1581
|
+
return refuse(response, who, 409, "The project instructions could not be installed. Review setup again.", "/control");
|
|
1582
|
+
return redirect(response, "/control");
|
|
1583
|
+
}
|
|
1584
|
+
const inputs = { provider: body.get("provider") ?? "", model: body.get("model") ?? "", command: body.get("command") ?? "", seconds: body.get("seconds") ?? "" };
|
|
1585
|
+
const preview = previewSetup(store, project, inputs);
|
|
1586
|
+
if (!preview.ok)
|
|
1587
|
+
return refuse(response, who, 400, preview.message, "/control");
|
|
1588
|
+
const nonceKey = `project-setup:${project}`;
|
|
1589
|
+
if (url.pathname.endsWith("preview")) {
|
|
1590
|
+
const nonce = mintApprovalNonce(who.name, nonceKey, preview.fingerprint);
|
|
1591
|
+
return sendScreen(response, 200, screen("Approve project setup", setupPreviewHtml(inputs, { csrf, repo: project, fingerprint: preview.fingerprint, nonce }), { chrome: chromeFor(project, "settings") }));
|
|
1592
|
+
}
|
|
1593
|
+
if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
|
|
1594
|
+
return refuse(response, who, 409, "This setup preview expired. Review it again.", "/control");
|
|
1595
|
+
const approved = approveSetup(store, project, inputs, body.get("fingerprint") ?? "", who.name, body.get("token") ?? "", now);
|
|
1596
|
+
if (!approved.ok)
|
|
1597
|
+
return refuse(response, who, 409, approved.message, "/control");
|
|
1598
|
+
return redirect(response, "/control");
|
|
1599
|
+
}
|
|
1600
|
+
if (["connect", "pair", "unpair", "disconnect", "alerts"].some(action => url.pathname === `/settings/slack/${action}`)) {
|
|
1601
|
+
const body = readForm(posted, CONSOLE_FORMS.slack);
|
|
1602
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
1603
|
+
return refuse(response, who, 403, "An installation approver can connect Slack.", "/settings");
|
|
1604
|
+
const dir = options.configDir, state = new SlackState(store), action = url.pathname.split("/").at(-1);
|
|
1605
|
+
const show = (problem, status = 400) => sendScreen(response, status, screen("Slack", slackSettingsHtml(store, dir, who.session.csrf, { problem, who: who.name }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
|
|
1606
|
+
if (["password", "app-token", "bot-token"].some(key => body.getAll(key).length > 1))
|
|
1607
|
+
return show("Submit one value for each field.");
|
|
1608
|
+
const credentials = loadSlackCredentials(dir);
|
|
1609
|
+
if (action === "alerts") {
|
|
1610
|
+
if (!credentials || !state.bindings(credentials.installation).some(one => state.live(one)))
|
|
1611
|
+
return show("Pair your Slack account first.", 409);
|
|
1612
|
+
savePrimary(dir, "slack");
|
|
1613
|
+
return redirect(response, "/settings/slack");
|
|
1614
|
+
}
|
|
1615
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
1616
|
+
return show("Enter your Toolroll password to change Slack access.", 403);
|
|
1617
|
+
const generation = store.accountOf(who.name).generation;
|
|
1618
|
+
if (action === "connect") {
|
|
1619
|
+
try {
|
|
1620
|
+
const checked = await checkSlackCredentials((body.get("app-token") ?? "").trim(), (body.get("bot-token") ?? "").trim(), options.slackFetcher);
|
|
1621
|
+
const account = store.accountOf(who.name);
|
|
1622
|
+
if (account?.role !== "approver" || account.generation !== generation || account.revokedAt !== null)
|
|
1623
|
+
return show("Your access changed. Sign in again.", 403);
|
|
1624
|
+
if (credentials || loadSlackCredentials(dir))
|
|
1625
|
+
return show("Disconnect the current Slack app before connecting another.", 409);
|
|
1626
|
+
saveSlackCredentials(dir, checked);
|
|
1627
|
+
}
|
|
1628
|
+
catch (error) {
|
|
1629
|
+
return show(error instanceof SlackError ? error.message : "Slack could not be connected. Check the tokens and try again.");
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
else if (action === "disconnect") {
|
|
1633
|
+
if (credentials)
|
|
1634
|
+
state.revoke(credentials.installation, now);
|
|
1635
|
+
clearSlackCredentials(dir);
|
|
1636
|
+
}
|
|
1637
|
+
else if (action === "unpair") {
|
|
1638
|
+
const mine = credentials ? state.bindings(credentials.installation).find(one => one.approver === who.name) : undefined;
|
|
1639
|
+
if (mine)
|
|
1640
|
+
state.revokeBinding(mine, now);
|
|
1641
|
+
}
|
|
1642
|
+
else if (action === "pair") {
|
|
1643
|
+
if (!credentials)
|
|
1644
|
+
return show("Connect your Slack app first.", 409);
|
|
1645
|
+
const mine = state.bindings(credentials.installation).find(one => one.approver === who.name);
|
|
1646
|
+
if (mine && state.live(mine))
|
|
1647
|
+
return show("Your Slack account is already paired. Unpair it before pairing another.", 409);
|
|
1648
|
+
if (mine)
|
|
1649
|
+
state.revokeBinding(mine, now);
|
|
1650
|
+
const code = state.pairing(credentials.installation, who.name, generation, now);
|
|
1651
|
+
return sendScreen(response, 200, screen("Pair Slack", slackSettingsHtml(store, dir, who.session.csrf, { code, who: who.name }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
|
|
1652
|
+
}
|
|
1653
|
+
return redirect(response, "/settings/slack");
|
|
1654
|
+
}
|
|
1655
|
+
if (url.pathname === "/settings/telegram/retry") {
|
|
1656
|
+
// The signed-in person's own unsent replies only: already written and fenced on the pairing; this sends them again now.
|
|
1657
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1658
|
+
return refuse(response, who, 403, "An approver can retry their own replies.", "/settings");
|
|
1659
|
+
store.retryTelegramReplies(who.name, now);
|
|
1660
|
+
return redirect(response, "/settings/telegram");
|
|
1661
|
+
}
|
|
1662
|
+
// Approving from chat, as the person's lasting setting (chat-approval.ts): for all their projects or one. Turning it
|
|
1663
|
+
// on or changing its limits is the mode's ceremony (the exact terms, a single-use nonce bound to them, the password
|
|
1664
|
+
// typed again); turning it off is one step, csrf only. Every change is a ledger line.
|
|
1665
|
+
if (url.pathname === "/settings/chat-approval/confirm" || url.pathname === "/settings/chat-approval/save" || url.pathname === "/settings/chat-approval/off") {
|
|
1666
|
+
const body = readForm(posted, CONSOLE_FORMS.chatApproval);
|
|
1667
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1668
|
+
return refuse(response, who, 403, "An approver turns on approving from their own chat.", "/settings");
|
|
1669
|
+
if (["scope", "full-access", "cap-usd", "nonce", "digest", "token"].some(key => body.getAll(key).length > 1))
|
|
1670
|
+
return refuse(response, who, 400, "Submit one value for each field.", "/settings");
|
|
1671
|
+
const given = (body.get("scope") ?? "all").trim();
|
|
1672
|
+
const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : canonicalProject(given) ?? given;
|
|
1673
|
+
if (scope !== CHAT_APPROVAL_ALL && !(visible(scope) && store.accountCanAccess(who.name, scope)))
|
|
1674
|
+
return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
|
|
1675
|
+
const where = scope === CHAT_APPROVAL_ALL ? "all your projects" : projectName(scope);
|
|
1676
|
+
if (url.pathname.endsWith("/off")) {
|
|
1677
|
+
const saved = setChatApproval(store, { approver: who.name, scope, enabled: false, via: "the console" }, now);
|
|
1678
|
+
return saved.ok ? redirect(response, `/settings?said=${encodeURIComponent(saved.said)}`) : refuse(response, who, 400, saved.message, "/settings");
|
|
1679
|
+
}
|
|
1680
|
+
const usd = (body.get("cap-usd") ?? "").trim();
|
|
1681
|
+
const cap = usd === "" ? null : Number(usd);
|
|
1682
|
+
if (cap !== null && !(Number.isFinite(cap) && cap >= 0 && cap <= 10_000))
|
|
1683
|
+
return refuse(response, who, 400, "Give the attempt limit in dollars, like 5.", "/settings");
|
|
1684
|
+
const limits = { fullAccess: body.get("full-access") === "1", capMicrousd: cap === null ? null : Math.round(cap * 1_000_000) };
|
|
1685
|
+
const digest = createHash("sha256").update(JSON.stringify({ scope, ...limits })).digest("hex");
|
|
1686
|
+
if (url.pathname.endsWith("/confirm")) {
|
|
1687
|
+
const nonce = mintApprovalNonce(who.name, "chat-approval", digest);
|
|
1688
|
+
const html = `<h1 style="overflow-wrap:anywhere">Approve from chat in ${escape(where)}</h1>` +
|
|
1689
|
+
`<form method="post" action="/settings/chat-approval/save" class="card approve-form">` +
|
|
1690
|
+
hiddenFields({ csrf: who.session.csrf, nonce, digest, scope: scope === CHAT_APPROVAL_ALL ? "all" : scope, "full-access": limits.fullAccess ? "1" : "", "cap-usd": usd }) +
|
|
1691
|
+
`<p>Your own taps in your paired chat approve plans and merges until you turn this off. It doesn't expire.</p>` +
|
|
1692
|
+
`<p class="recap">Limits: ${escape(chatApprovalWords(limits))}.</p>` +
|
|
1693
|
+
`<p class="recap">Plans written for you, protected paths, two-person rules and your organisation's policy still open in Toolroll.</p>` +
|
|
1694
|
+
`<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
1695
|
+
`<div class="sticky-actions"><button type="submit">Turn on</button></div></form>` +
|
|
1696
|
+
`<p class="meta"><a href="/settings">Back</a></p>`;
|
|
1697
|
+
return sendScreen(response, 200, screen("Approve from chat", html, { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
|
|
1698
|
+
}
|
|
1699
|
+
if (!consumeApprovalNonce(body.get("nonce") ?? "", who.name, "chat-approval", body.get("digest") ?? "") || body.get("digest") !== digest)
|
|
1700
|
+
return refuse(response, who, 409, "That form is stale. Read the terms again.", "/settings");
|
|
1701
|
+
const proved = authenticateAccount(store, who.name, body.get("token") ?? "");
|
|
1702
|
+
if (!proved.ok || proved.role !== "approver" || proved.generation !== who.session.generation)
|
|
1703
|
+
return refuse(response, who, 403, "Turning on approving from chat takes your password, typed again.", "/settings");
|
|
1704
|
+
const saved = setChatApproval(store, { approver: who.name, scope, enabled: true, limits, via: "the console" }, now);
|
|
1705
|
+
return saved.ok ? redirect(response, `/settings?said=${encodeURIComponent(saved.said)}`) : refuse(response, who, 400, saved.message, "/settings");
|
|
1706
|
+
}
|
|
1707
|
+
if (url.pathname === "/settings/telegram/pair" || url.pathname === "/settings/telegram/unpair") {
|
|
1708
|
+
const body = readForm(posted, CONSOLE_FORMS.telegramPair);
|
|
1709
|
+
// The person's own pairing, under their password: a code minted for
|
|
1710
|
+
// them alone, or their own chats revoked. Teammates' pairings are
|
|
1711
|
+
// never touched from here.
|
|
1712
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
1713
|
+
return refuse(response, who, 403, "An approver can pair their own phone.", "/settings");
|
|
1714
|
+
const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
|
|
1715
|
+
const show = (problem, status = 400) => sendScreen(response, status, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { problem }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
|
|
1716
|
+
if (body.getAll("password").length > 1)
|
|
1717
|
+
return show("Submit one value for each field.");
|
|
1718
|
+
if (botId === null)
|
|
1719
|
+
return show("Connect the Telegram bot first.", 409);
|
|
1720
|
+
const pairingIdentity = authenticateAccount(store, who.name, body.get("password") ?? "");
|
|
1721
|
+
if (!pairingIdentity.ok || pairingIdentity.role !== "approver" || pairingIdentity.generation !== who.session.generation)
|
|
1722
|
+
return show("Enter your Toolroll password to change your phone pairing.", 403);
|
|
1723
|
+
if (url.pathname.endsWith("/unpair")) {
|
|
1724
|
+
store.unpairTelegram(botId, who.name, now);
|
|
1725
|
+
return redirect(response, "/settings/telegram");
|
|
1726
|
+
}
|
|
1727
|
+
if (store.liveTelegramBindings(botId).some(binding => binding.approver === who.name))
|
|
1728
|
+
return show("Your phone is already paired. Unpair it before pairing another.", 409);
|
|
1729
|
+
const code = mintPairingCode();
|
|
1730
|
+
store.createTelegramPairing({ codeHash: hashPairingCode(code), approver: who.name, by: who.name, ttlMs: PAIRING_TTL_MS }, now);
|
|
1731
|
+
return sendScreen(response, 200, screen("Pair Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { code }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
|
|
1732
|
+
}
|
|
1733
|
+
if (["connect", "pair", "unpair", "disconnect", "alerts"].some(action => url.pathname === `/settings/teams/${action}`)) {
|
|
1734
|
+
const body = readForm(posted, CONSOLE_FORMS.teams);
|
|
1735
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
1736
|
+
return refuse(response, who, 403, "An installation approver can connect Teams.", "/settings");
|
|
1737
|
+
const dir = options.configDir, state = new ChatState(store, "teams"), action = url.pathname.split("/").at(-1);
|
|
1738
|
+
const show = (problem, status = 400) => sendScreen(response, status, screen("Teams", teamsSettingsHtml(store, dir, who.session.csrf, { problem, who: who.name, publicUrl: options.publicUrl ?? null }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
|
|
1739
|
+
if (["password", "app-id", "tenant", "secret"].some(key => body.getAll(key).length > 1))
|
|
1740
|
+
return show("Submit one value for each field.");
|
|
1741
|
+
const credentials = loadTeamsCredentials(dir);
|
|
1742
|
+
if (action === "alerts") {
|
|
1743
|
+
if (!credentials || !state.bindings(credentials.installation).some(one => state.live(one)))
|
|
1744
|
+
return show("Pair your Teams account first.", 409);
|
|
1745
|
+
savePrimary(dir, "teams");
|
|
1746
|
+
return redirect(response, "/settings/teams");
|
|
1747
|
+
}
|
|
1748
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
1749
|
+
return show("Enter your Toolroll password to change Teams access.", 403);
|
|
1750
|
+
const generation = store.accountOf(who.name).generation;
|
|
1751
|
+
if (action === "connect") {
|
|
1752
|
+
try {
|
|
1753
|
+
const checked = await checkTeamsCredentials((body.get("app-id") ?? "").trim(), (body.get("tenant") ?? "").trim(), (body.get("secret") ?? "").trim(), options.teamsFetcher);
|
|
1754
|
+
const account = store.accountOf(who.name);
|
|
1755
|
+
if (account?.role !== "approver" || account.generation !== generation || account.revokedAt !== null)
|
|
1756
|
+
return show("Your access changed. Sign in again.", 403);
|
|
1757
|
+
if (credentials || loadTeamsCredentials(dir))
|
|
1758
|
+
return show("Disconnect the current Teams app before connecting another.", 409);
|
|
1759
|
+
saveTeamsCredentials(dir, checked);
|
|
1760
|
+
}
|
|
1761
|
+
catch (error) {
|
|
1762
|
+
return show(error instanceof TeamsError ? error.message : "Teams could not be connected. Check the app id, tenant and secret.");
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
else if (action === "disconnect") {
|
|
1766
|
+
if (credentials)
|
|
1767
|
+
state.revoke(credentials.installation, now);
|
|
1768
|
+
clearTeamsCredentials(dir);
|
|
1769
|
+
}
|
|
1770
|
+
else if (action === "unpair") {
|
|
1771
|
+
const mine = credentials ? state.bindings(credentials.installation).find(one => one.approver === who.name) : undefined;
|
|
1772
|
+
if (mine)
|
|
1773
|
+
state.revokeBinding(mine, now);
|
|
1774
|
+
}
|
|
1775
|
+
else if (action === "pair") {
|
|
1776
|
+
if (!credentials)
|
|
1777
|
+
return show("Connect your Teams app first.", 409);
|
|
1778
|
+
const mine = state.bindings(credentials.installation).find(one => one.approver === who.name);
|
|
1779
|
+
if (mine && state.live(mine))
|
|
1780
|
+
return show("Your Teams account is already paired. Unpair it before pairing another.", 409);
|
|
1781
|
+
if (mine)
|
|
1782
|
+
state.revokeBinding(mine, now);
|
|
1783
|
+
const code = state.pairing(credentials.installation, who.name, generation, now);
|
|
1784
|
+
return sendScreen(response, 200, screen("Pair Teams", teamsSettingsHtml(store, dir, who.session.csrf, { code, who: who.name, publicUrl: options.publicUrl ?? null }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
|
|
1785
|
+
}
|
|
1786
|
+
return redirect(response, "/settings/teams");
|
|
1787
|
+
}
|
|
1788
|
+
if (["connect", "pair", "unpair", "disconnect", "alerts"].some(action => url.pathname === `/settings/discord/${action}`)) {
|
|
1789
|
+
const body = readForm(posted, CONSOLE_FORMS.discord);
|
|
1790
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
1791
|
+
return refuse(response, who, 403, "An installation approver can connect Discord.", "/settings");
|
|
1792
|
+
const dir = options.configDir, state = new ChatState(store, "discord"), action = url.pathname.split("/").at(-1);
|
|
1793
|
+
const show = (problem, status = 400) => sendScreen(response, status, screen("Discord", discordSettingsHtml(store, dir, who.session.csrf, { problem, who: who.name }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
|
|
1794
|
+
if (["password", "bot-token"].some(key => body.getAll(key).length > 1))
|
|
1795
|
+
return show("Submit one value for each field.");
|
|
1796
|
+
const credentials = loadDiscordCredentials(dir);
|
|
1797
|
+
if (action === "alerts") {
|
|
1798
|
+
if (!credentials || !state.bindings(credentials.installation).some(one => state.live(one)))
|
|
1799
|
+
return show("Pair your Discord account first.", 409);
|
|
1800
|
+
savePrimary(dir, "discord");
|
|
1801
|
+
return redirect(response, "/settings/discord");
|
|
1802
|
+
}
|
|
1803
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
1804
|
+
return show("Enter your Toolroll password to change Discord access.", 403);
|
|
1805
|
+
const generation = store.accountOf(who.name).generation;
|
|
1806
|
+
if (action === "connect") {
|
|
1807
|
+
try {
|
|
1808
|
+
const checked = await checkDiscordCredentials((body.get("bot-token") ?? "").trim(), options.discordFetcher);
|
|
1809
|
+
const account = store.accountOf(who.name);
|
|
1810
|
+
if (account?.role !== "approver" || account.generation !== generation || account.revokedAt !== null)
|
|
1811
|
+
return show("Your access changed. Sign in again.", 403);
|
|
1812
|
+
if (credentials || loadDiscordCredentials(dir))
|
|
1813
|
+
return show("Disconnect the current Discord app before connecting another.", 409);
|
|
1814
|
+
saveDiscordCredentials(dir, checked);
|
|
1815
|
+
}
|
|
1816
|
+
catch (error) {
|
|
1817
|
+
return show(error instanceof DiscordError ? error.message : "Discord could not be connected. Check the token and try again.");
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
else if (action === "disconnect") {
|
|
1821
|
+
if (credentials)
|
|
1822
|
+
state.revoke(credentials.installation, now);
|
|
1823
|
+
clearDiscordCredentials(dir);
|
|
1824
|
+
}
|
|
1825
|
+
else if (action === "unpair") {
|
|
1826
|
+
const mine = credentials ? state.bindings(credentials.installation).find(one => one.approver === who.name) : undefined;
|
|
1827
|
+
if (mine)
|
|
1828
|
+
state.revokeBinding(mine, now);
|
|
1829
|
+
}
|
|
1830
|
+
else if (action === "pair") {
|
|
1831
|
+
if (!credentials)
|
|
1832
|
+
return show("Connect your Discord app first.", 409);
|
|
1833
|
+
const mine = state.bindings(credentials.installation).find(one => one.approver === who.name);
|
|
1834
|
+
if (mine && state.live(mine))
|
|
1835
|
+
return show("Your Discord account is already paired. Unpair it before pairing another.", 409);
|
|
1836
|
+
if (mine)
|
|
1837
|
+
state.revokeBinding(mine, now);
|
|
1838
|
+
const code = state.pairing(credentials.installation, who.name, generation, now);
|
|
1839
|
+
return sendScreen(response, 200, screen("Pair Discord", discordSettingsHtml(store, dir, who.session.csrf, { code, who: who.name }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
|
|
1840
|
+
}
|
|
1841
|
+
return redirect(response, "/settings/discord");
|
|
1842
|
+
}
|
|
1843
|
+
if (url.pathname === "/settings/messaging" && options.configDir !== undefined && options.telegramTokenFile !== undefined) {
|
|
1844
|
+
const body = readForm(posted, CONSOLE_FORMS.messaging);
|
|
1845
|
+
const wanted = (body.get("primary") ?? "").trim();
|
|
1846
|
+
const facts = effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null);
|
|
1847
|
+
// Only a CONFIGURED service may page — choosing silence is not a
|
|
1848
|
+
// selection, and an unconfigured primary would page nobody.
|
|
1849
|
+
if (!isMessagingChannel(wanted) || !facts.configured.includes(wanted)) {
|
|
1850
|
+
return refuse(response, who, 400, "primary must be one of the configured services");
|
|
1851
|
+
}
|
|
1852
|
+
savePrimary(options.configDir, wanted);
|
|
1853
|
+
return redirect(response, "/settings");
|
|
1854
|
+
}
|
|
1855
|
+
if (url.pathname === "/settings/permission-default" && options.telegramTokenFile !== undefined) {
|
|
1856
|
+
const body = readForm(posted, CONSOLE_FORMS.permissionDefault);
|
|
1857
|
+
const wanted = body.get("permission-mode");
|
|
1858
|
+
if (wanted !== "auto" && wanted !== "bypassPermissions") {
|
|
1859
|
+
return refuse(response, who, 400, "permissions must be Auto or Full access", "/settings");
|
|
1860
|
+
}
|
|
1861
|
+
store.setPermissionDefault(wanted, who.name, now);
|
|
1862
|
+
return redirect(response, `/settings?said=${encodeURIComponent(wanted === "bypassPermissions"
|
|
1863
|
+
? "New tasks now start with Full access. Approved tasks keep their setting."
|
|
1864
|
+
: "New tasks now start with Auto. Approved tasks keep their setting.")}`);
|
|
1865
|
+
}
|
|
1866
|
+
if (url.pathname === "/settings/quality-default" && options.telegramTokenFile !== undefined) {
|
|
1867
|
+
const body = readForm(posted, CONSOLE_FORMS.qualityDefault);
|
|
1868
|
+
const wanted = body.get("quality-mode");
|
|
1869
|
+
if (!isQualityMode(wanted)) {
|
|
1870
|
+
return refuse(response, who, 400, "quality must be Default or Strict / release", "/settings");
|
|
1871
|
+
}
|
|
1872
|
+
store.setQualityDefault(wanted, who.name, now);
|
|
1873
|
+
return redirect(response, `/settings?said=${encodeURIComponent(wanted === "strict"
|
|
1874
|
+
? "New tasks now use Strict / release. Approved tasks keep their setting."
|
|
1875
|
+
: "New tasks now use Default quality. Approved tasks keep their setting.")}`);
|
|
1876
|
+
}
|
|
1877
|
+
if (url.pathname === "/settings/notifications") {
|
|
1878
|
+
const body = readForm(posted, CONSOLE_FORMS.notifications);
|
|
1879
|
+
// Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
|
|
1880
|
+
const mode = body.get("mode"), time = (body.get("digest") ?? "").trim(), shots = body.get("screenshots");
|
|
1881
|
+
if (mode !== null && mode !== "quiet" && mode !== "all")
|
|
1882
|
+
return refuse(response, who, 400, "choose Only when I'm needed or Every step", "/settings");
|
|
1883
|
+
if (time !== "" && time !== "off" && !isDigestTime(time))
|
|
1884
|
+
return refuse(response, who, 400, "the evening digest time is HH:MM, or off", "/settings");
|
|
1885
|
+
if (shots !== null && !RESULT_SCREENSHOTS.includes(shots))
|
|
1886
|
+
return refuse(response, who, 400, "choose Off, First one or Up to 4", "/settings");
|
|
1887
|
+
const before = store.notificationPreference(who.name);
|
|
1888
|
+
const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }),
|
|
1889
|
+
...(shots === null ? {} : { screenshots: shots }) }, who.name, now);
|
|
1890
|
+
const said = after.mode !== before.mode
|
|
1891
|
+
? after.mode === "quiet" ? "Chats now message you only when you're needed." : "Chats now message you at every step."
|
|
1892
|
+
: after.screenshots !== before.screenshots
|
|
1893
|
+
? after.screenshots === "off" ? "Results arrive without screenshots." : `Results arrive with ${after.screenshots === "first" ? "their first screenshot" : "up to 4 screenshots"}.`
|
|
1894
|
+
: after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
|
|
1895
|
+
return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
|
|
1896
|
+
}
|
|
1897
|
+
if (url.pathname === "/settings/notifications/mute") {
|
|
1898
|
+
const body = readForm(posted, CONSOLE_FORMS.notificationsMute);
|
|
1899
|
+
// Each person's own pings: a muted project stays in Tasks and the evening digest.
|
|
1900
|
+
const repo = body.get("repo") ?? "";
|
|
1901
|
+
const project = notificationProjects(store, who.name).find(one => one.repo === repo);
|
|
1902
|
+
if (project === undefined)
|
|
1903
|
+
return refuse(response, who, 400, "that project isn't one you can see", "/settings");
|
|
1904
|
+
const muted = body.get("pings") !== "on";
|
|
1905
|
+
store.setProjectMuted(who.name, repo, muted, now);
|
|
1906
|
+
return redirect(response, `/settings?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
|
|
1907
|
+
}
|
|
1908
|
+
if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
|
|
1909
|
+
const body = readForm(posted, CONSOLE_FORMS.telegramDigest);
|
|
1910
|
+
// The cadence is a closed list of minutes — never a free number from
|
|
1911
|
+
// a form; "off" clears it. Any approver session may set it.
|
|
1912
|
+
const wanted = (body.get("every") ?? "").trim();
|
|
1913
|
+
const allowed = { off: null, "30": 30, "60": 60, "240": 240, "720": 720, "1440": 1440 };
|
|
1914
|
+
if (!(wanted in allowed))
|
|
1915
|
+
return refuse(response, who, 400, "the digest cadence is one of the listed choices", "/settings");
|
|
1916
|
+
const minutes = allowed[wanted] ?? null;
|
|
1917
|
+
store.setTelegramDigest(minutes === null ? null : minutes * 60_000, who.name, now);
|
|
1918
|
+
return redirect(response, `/settings?said=${encodeURIComponent(minutes === null ? "Digest off: each update arrives as it happens." : `Digest every ${minutes >= 60 ? `${minutes / 60}h` : `${minutes}m`}. Anything that needs you still arrives at once.`)}`);
|
|
1919
|
+
}
|
|
1920
|
+
if (url.pathname === "/settings/provider-key" || url.pathname === "/settings/provider-key-clear") {
|
|
1921
|
+
const body = readForm(posted, CONSOLE_FORMS.providerKey);
|
|
1922
|
+
// The central gate already required an ACTIVE approver; the value is
|
|
1923
|
+
// write-only from here — status pages say set/not-set, never bytes.
|
|
1924
|
+
const provider = body.get("provider") ?? "";
|
|
1925
|
+
if (!isProviderId(provider))
|
|
1926
|
+
return refuse(response, who, 400, "unknown provider", "/settings");
|
|
1927
|
+
if (url.pathname === "/settings/provider-key-clear") {
|
|
1928
|
+
const cleared = clearProviderKey(provider, providerHome);
|
|
1929
|
+
const clearedMode = readAuthMode(provider, providerHome);
|
|
1930
|
+
return redirect(response, `/settings?said=${encodeURIComponent(!cleared
|
|
1931
|
+
? "no stored key to remove"
|
|
1932
|
+
: clearedMode === "subscription"
|
|
1933
|
+
? `the ${provider} key is removed — ${provider} uses its own login, so builds are unaffected`
|
|
1934
|
+
: `the ${provider} key is removed — an environment variable, if one exists, takes over`)}`);
|
|
1935
|
+
}
|
|
1936
|
+
// Validate EVERYTHING before mutating anything (Codex round 5,
|
|
1937
|
+
// finding 2): an invalid key must never persist a mode change it
|
|
1938
|
+
// then hides, a mode equal to the current one is not a "change",
|
|
1939
|
+
// and an unchanged blank form does nothing.
|
|
1940
|
+
const wantedMode = body.get("auth-mode");
|
|
1941
|
+
const value = (body.get("value") ?? "").trim();
|
|
1942
|
+
const modeSelected = wantedMode === "subscription" || wantedMode === "api-key";
|
|
1943
|
+
const currentMode = readAuthMode(provider, providerHome);
|
|
1944
|
+
if (!modeSelected && value === "") {
|
|
1945
|
+
return refuse(response, who, 400, "nothing to change — paste a key or pick a sign-in", "/settings");
|
|
1946
|
+
}
|
|
1947
|
+
// A selected-but-inapplicable mode (openrouter subscription) refuses
|
|
1948
|
+
// before any write.
|
|
1949
|
+
if (modeSelected && wantedMode === "subscription" && !SUBSCRIPTION_CAPABLE[provider]) {
|
|
1950
|
+
return refuse(response, who, 409, `${provider} has no subscription login — it is API-key only`, "/settings");
|
|
1951
|
+
}
|
|
1952
|
+
// A present key must be plausible BEFORE the mode is touched.
|
|
1953
|
+
if (value !== "" && !plausibleKey(value)) {
|
|
1954
|
+
return refuse(response, who, 400, "that does not look like an API key — check the paste and try again", "/settings");
|
|
1955
|
+
}
|
|
1956
|
+
// Apply the mode only when it actually differs.
|
|
1957
|
+
const modeChanged = modeSelected && wantedMode !== currentMode;
|
|
1958
|
+
if (modeChanged)
|
|
1959
|
+
setAuthMode(provider, wantedMode, providerHome);
|
|
1960
|
+
const modeNote = modeChanged ? ` \u00b7 ${provider} now uses ${wantedMode === "api-key" ? "the API key" : "its own subscription / login"}` : "";
|
|
1961
|
+
if (value === "") {
|
|
1962
|
+
return redirect(response, `/settings?said=${encodeURIComponent(modeChanged ? `${provider} now uses ${wantedMode === "api-key" ? "the API key" : "its own subscription / login"}` : `no change — ${provider} already uses ${currentMode === "api-key" ? "the API key" : "its own subscription / login"}`)}`);
|
|
1963
|
+
}
|
|
1964
|
+
saveProviderKey(provider, value, providerHome); // known plausible
|
|
1965
|
+
// Verify right now, so a paste gets an immediate yes/no instead of a
|
|
1966
|
+
// failed build later. A stored-but-unreachable key still says so.
|
|
1967
|
+
// In API-key mode the account check tests the stored key, and remembers the answer until the key changes.
|
|
1968
|
+
const checked = readAuthMode(provider, providerHome) === "api-key" ? await connectionCheck(provider, true) : null;
|
|
1969
|
+
const verdict = checked?.verdict ?? await verifyProviderKey(provider, value);
|
|
1970
|
+
const stored = `the ${provider} key is stored`;
|
|
1971
|
+
return redirect(response, `/settings?said=${encodeURIComponent((verdict.ok ? `${stored} and verified — it works` : `${stored}. ${verdictWords(provider, verdict)}`) + modeNote)}`);
|
|
1972
|
+
}
|
|
1973
|
+
if (url.pathname === "/settings/telegram-token" && options.telegramTokenFile !== undefined) {
|
|
1974
|
+
const body = readForm(posted, CONSOLE_FORMS.telegramToken);
|
|
1975
|
+
const value = body.get("token") ?? "";
|
|
1976
|
+
const saved = saveBotToken(options.telegramTokenFile, value);
|
|
1977
|
+
if (!saved.ok) {
|
|
1978
|
+
const existing = loadBotToken({}, options.telegramTokenFile);
|
|
1979
|
+
const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
|
|
1980
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
1981
|
+
return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : defaultProject, "settings"), existing, hasEnv, csrf, saved.message, null, null, null, null, {
|
|
1982
|
+
...store.permissionDefault(),
|
|
1983
|
+
canManage: who.role === "approver",
|
|
1984
|
+
}));
|
|
1985
|
+
}
|
|
1986
|
+
return redirect(response, "/settings");
|
|
1987
|
+
}
|
|
1988
|
+
// v87: the mail server Send email steps use, and a test email to its own address.
|
|
1989
|
+
if ((url.pathname === "/settings/email" || url.pathname === "/settings/email-test" || url.pathname === "/settings/email-read-test") && who.role === "approver" && options.configDir !== undefined) {
|
|
1990
|
+
const body = readForm(posted, CONSOLE_FORMS.email);
|
|
1991
|
+
const said = (words) => redirect(response, `/settings?said=${encodeURIComponent(words)}#email`);
|
|
1992
|
+
if (url.pathname === "/settings/email") {
|
|
1993
|
+
const saved = saveEmailSettings(options.configDir, { host: body.get("host"), port: body.get("port"), secure: body.get("secure"), user: body.get("user"), from: body.get("from"), password: body.get("password"), imapHost: body.get("imapHost"), imapPort: body.get("imapPort") });
|
|
1994
|
+
return said(saved.ok ? saved.said : saved.message);
|
|
1995
|
+
}
|
|
1996
|
+
if (url.pathname === "/settings/email-read-test") {
|
|
1997
|
+
// Sign in and open the inbox read-only, as an Inbox trigger's first check does: nothing is read or changed.
|
|
1998
|
+
const signed = await mailboxAccess(options.configDir, options.googleFetch ?? fetch);
|
|
1999
|
+
if (!signed.ok)
|
|
2000
|
+
return said(signed.said);
|
|
2001
|
+
const opened = await (options.flowTriggerIo?.mail ?? readThroughImap)(signed.access, "INBOX", null, 1);
|
|
2002
|
+
return said(opened.ok ? `Reading works: signed in to the inbox of ${signed.address}.` : opened.said);
|
|
2003
|
+
}
|
|
2004
|
+
const account = await sendingAccount(options.configDir, options.googleFetch ?? fetch);
|
|
2005
|
+
if (!account.ok)
|
|
2006
|
+
return said(account.said === "Email isn't set up yet. Add your mail server in Settings → Email." ? "Set up email first." : account.said);
|
|
2007
|
+
const settings = account.settings;
|
|
2008
|
+
const sent = await (options.mailSender ?? sendThroughServer)(settings, { from: settings.from, to: [settings.from], subject: "Toolroll: test email", text: "This is a test from Toolroll. Send email steps in your flows will come from this address." });
|
|
2009
|
+
return said(sent.ok ? `Sent a test email to ${settings.from}.` : sent.said);
|
|
2010
|
+
}
|
|
2011
|
+
// v89: a Google account for email: save the OAuth client and go to Google's consent screen, or disconnect.
|
|
2012
|
+
if ((url.pathname === "/settings/google" || url.pathname === "/settings/google/disconnect") && who.role === "approver" && options.configDir !== undefined) {
|
|
2013
|
+
const body = readForm(posted, CONSOLE_FORMS.google);
|
|
2014
|
+
const said = (words) => redirect(response, `/settings?said=${encodeURIComponent(words)}#email`);
|
|
2015
|
+
if (url.pathname === "/settings/google/disconnect") {
|
|
2016
|
+
await disconnectGoogle(options.configDir, options.googleFetch ?? fetch);
|
|
2017
|
+
return said("Google disconnected. Email goes through the mail server again, if one is set up.");
|
|
2018
|
+
}
|
|
2019
|
+
const saved = saveGoogleClient(options.configDir, { clientId: body.get("clientId"), clientSecret: body.get("clientSecret") });
|
|
2020
|
+
if (!saved.ok)
|
|
2021
|
+
return said(saved.message);
|
|
2022
|
+
const origin = consoleOrigin(request.headers.host);
|
|
2023
|
+
if (origin === null)
|
|
2024
|
+
return said("Connect Google from this computer (localhost) or from your https address.");
|
|
2025
|
+
const consent = googleConsent(readGoogleMail(options.configDir), `${origin}${GOOGLE_CALLBACK}`, who.name);
|
|
2026
|
+
for (const [key, visit] of googleVisits)
|
|
2027
|
+
if (visit.expires < Date.now())
|
|
2028
|
+
googleVisits.delete(key);
|
|
2029
|
+
googleVisits.set(consent.visit.state, consent.visit);
|
|
2030
|
+
return goOutside(response, consent.url, "Going to Google to sign in…", { state: consent.visit.state, path: GOOGLE_CALLBACK, secure: origin.startsWith("https:") });
|
|
2031
|
+
}
|
|
2032
|
+
if (url.pathname === "/mode/confirm" || url.pathname === "/mode/sign") {
|
|
2033
|
+
const body = readForm(posted, CONSOLE_FORMS.mode);
|
|
2034
|
+
if (who.via !== "cookie")
|
|
2035
|
+
return refuse(response, who, 403, "signing a mode is a browser ceremony");
|
|
2036
|
+
const project = projectOf(who, request);
|
|
2037
|
+
if (project === null || project === undefined) {
|
|
2038
|
+
return refuse(response, who, 409, "open a project first — a mode is signed per repository", "/mode");
|
|
2039
|
+
}
|
|
2040
|
+
const name = body.get("name") === "hands-off" ? "hands-off" : "standard";
|
|
2041
|
+
const days = Math.max(1, Math.min(MODE_MAX_DAYS, Math.floor(Number(body.get("days") ?? "1")) || 1));
|
|
2042
|
+
const expiry = new Date(now.getTime() + days * 24 * 60 * 60_000).toISOString();
|
|
2043
|
+
const preset = presetTerms(name, expiry);
|
|
2044
|
+
const terms = {
|
|
2045
|
+
...preset,
|
|
2046
|
+
autoApproveFiling: body.get("auto-approve") === "" || body.get("auto-approve") === null ? preset.autoApproveFiling : body.get("auto-approve") === "1",
|
|
2047
|
+
planAuto: body.get("plan-auto") === "1",
|
|
2048
|
+
reviewAuto: false,
|
|
2049
|
+
reviewRetryAuto: false,
|
|
2050
|
+
// The paid-fallback grant is NEVER a preset default (R8): unchecked
|
|
2051
|
+
// stays false on every preset — only the explicit box grants it.
|
|
2052
|
+
allowPaidFallback: body.get("allow-paid-fallback") === "1",
|
|
2053
|
+
// The repair-auto grant is the SAME rule (v40): unchecked stays
|
|
2054
|
+
// false on every preset — only the explicit box grants it, and the
|
|
2055
|
+
// attempt cap it carries is meaningless without it.
|
|
2056
|
+
repairAuto: false,
|
|
2057
|
+
repairMaxAttempts: 0,
|
|
2058
|
+
// Approving from the paired chat: only the explicit choice grants it.
|
|
2059
|
+
chatApprove: body.get("chat-approve") === "1",
|
|
2060
|
+
publication: body.get("publication") === "automerge" ? "automerge" : "notify",
|
|
2061
|
+
};
|
|
2062
|
+
if (["review-auto", "review-retry-auto", "repair-auto"].some(field => body.get(field) === "1")) {
|
|
2063
|
+
return refuse(response, who, 400, "Review scheduling and automatic revisions are no longer available. Reload the mode form.", "/mode");
|
|
2064
|
+
}
|
|
2065
|
+
if (terms.planAuto && !terms.autoApproveFiling) {
|
|
2066
|
+
return refuse(response, who, 400, "Automatic planner approval requires automatic filing approval.", "/mode");
|
|
2067
|
+
}
|
|
2068
|
+
if (terms.publication === "automerge" && !store.hasMergeCapableGrant(project, now)) {
|
|
2069
|
+
return refuse(response, who, 409, "self-merging needs a merge-capable publication grant first — grant one, then sign", "/mode");
|
|
2070
|
+
}
|
|
2071
|
+
const digest = modeDigestOf(terms);
|
|
2072
|
+
if (url.pathname === "/mode/confirm") {
|
|
2073
|
+
// THE CEREMONY (M1): every resolved term in words, and the password
|
|
2074
|
+
// signs exactly this digest — a drifted form refuses at /mode/sign.
|
|
2075
|
+
const nonce = mintApprovalNonce(who.name, "mode-sign", `${project}:${digest}`);
|
|
2076
|
+
const bodyHtml = `<h1 style="overflow-wrap:anywhere">Sign the ${escape(name)} mode for ${escape(project)}</h1>` +
|
|
2077
|
+
`<form method="post" action="/mode/sign" class="card approve-form">` +
|
|
2078
|
+
`<input type="hidden" name="csrf" value="${escape(who.session.csrf)}">` +
|
|
2079
|
+
`<input type="hidden" name="nonce" value="${escape(nonce)}">` +
|
|
2080
|
+
`<input type="hidden" name="digest" value="${escape(digest)}">` +
|
|
2081
|
+
["name", "days", "publication", "auto-approve", "plan-auto", "chat-approve", "allow-paid-fallback"]
|
|
2082
|
+
.map(field => `<input type="hidden" name="${field}" value="${escape(body.get(field) ?? "")}">`)
|
|
2083
|
+
.join("") +
|
|
2084
|
+
`<input type="hidden" name="expiry" value="${escape(expiry)}">` +
|
|
2085
|
+
`<p><strong>Your password signs exactly this:</strong></p>` +
|
|
2086
|
+
modeWords(terms)
|
|
2087
|
+
.map(words => `<p class="recap" style="margin-top:.4rem">${escape(words)}</p>`)
|
|
2088
|
+
.join("") +
|
|
2089
|
+
`<label>Your password, typed again<input type="password" name="token" autocomplete="current-password"></label>` +
|
|
2090
|
+
`<div class="sticky-actions"><button type="submit">Sign it</button></div>` +
|
|
2091
|
+
`</form>` +
|
|
2092
|
+
`<p class="meta"><a href="/mode">back — sign nothing</a></p>`;
|
|
2093
|
+
return sendScreen(response, 200, screen("mode", bodyHtml, { chrome: chromeFor(project, "mode") }));
|
|
2094
|
+
}
|
|
2095
|
+
// /mode/sign: the fixed expiry rides the form; the digest is
|
|
2096
|
+
// RE-DERIVED from the posted fields — drift is a 409, never a guess.
|
|
2097
|
+
const fixedExpiry = body.get("expiry") ?? "";
|
|
2098
|
+
const signedTerms = { ...terms, absoluteExpiry: fixedExpiry };
|
|
2099
|
+
const rederived = modeDigestOf(signedTerms);
|
|
2100
|
+
const nonce = body.get("nonce") ?? "";
|
|
2101
|
+
if (!consumeApprovalNonce(nonce, who.name, "mode-sign", `${project}:${body.get("digest") ?? ""}`)) {
|
|
2102
|
+
return refuse(response, who, 409, "that form is stale — read the terms again", "/mode");
|
|
2103
|
+
}
|
|
2104
|
+
if (rederived !== (body.get("digest") ?? "") || Date.parse(fixedExpiry) <= now.getTime()) {
|
|
2105
|
+
return refuse(response, who, 409, "the terms moved while you were reading — read them again", "/mode");
|
|
2106
|
+
}
|
|
2107
|
+
const token = body.get("token") ?? "";
|
|
2108
|
+
if (!authenticateApprover(who, token).ok) {
|
|
2109
|
+
return refuse(response, who, 403, "signing a mode takes your password, typed again", "/mode");
|
|
2110
|
+
}
|
|
2111
|
+
store.signMode({
|
|
2112
|
+
repo: project,
|
|
2113
|
+
name: signedTerms.name,
|
|
2114
|
+
termsJson: modeTermsJson(signedTerms),
|
|
2115
|
+
digest: rederived,
|
|
2116
|
+
signedBy: who.name,
|
|
2117
|
+
absoluteExpiry: signedTerms.absoluteExpiry,
|
|
2118
|
+
publication: signedTerms.publication,
|
|
2119
|
+
}, now);
|
|
2120
|
+
return redirect(response, "/mode");
|
|
2121
|
+
}
|
|
2122
|
+
if (url.pathname === "/mode/revoke") {
|
|
2123
|
+
if (who.via !== "cookie")
|
|
2124
|
+
return refuse(response, who, 403, "ending a mode is a browser act");
|
|
2125
|
+
const project = projectOf(who, request);
|
|
2126
|
+
if (project === null || project === undefined)
|
|
2127
|
+
return refuse(response, who, 409, "open a project first", "/mode");
|
|
2128
|
+
// LOWERING authority is ONE CLICK for any approver (the v4 ruling):
|
|
2129
|
+
// csrf only, no password — the fastest possible off-switch.
|
|
2130
|
+
const ended = store.revokeMode(project, who.name, "operator", now);
|
|
2131
|
+
return redirect(response, ended ? "/mode?said=the%20mode%20is%20ended%20—%20every%20act%20falls%20back%20to%20its%20own%20ceremony" : "/mode");
|
|
2132
|
+
}
|
|
2133
|
+
// Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
|
|
2134
|
+
// conversation it needs no second password; the full form (Advanced) still asks for one.
|
|
2135
|
+
// Settings → Lead: the name and persona this person's lead speaks with.
|
|
2136
|
+
if (url.pathname === "/settings/lead/identity") {
|
|
2137
|
+
const body = readForm(posted, CONSOLE_FORMS.leadIdentity);
|
|
2138
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
2139
|
+
return refuse(response, who, 403, "An approver names their lead.", "/settings/lead");
|
|
2140
|
+
const checked = checkLeadIdentity(body.get("name") ?? "", body.get("persona") ?? "");
|
|
2141
|
+
if (!checked.ok)
|
|
2142
|
+
return redirect(response, chatReturnWithSaid("/settings/lead", checked.message));
|
|
2143
|
+
store.setLeadConfig(who.name, checked.identity.name, checked.identity.persona, now);
|
|
2144
|
+
return redirect(response, "/settings/lead?saved=1");
|
|
2145
|
+
}
|
|
2146
|
+
// Settings → Lead: what this person's lead knows about them, one line each.
|
|
2147
|
+
if (url.pathname === "/settings/lead/about") {
|
|
2148
|
+
const body = readForm(posted, CONSOLE_FORMS.leadAbout);
|
|
2149
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
2150
|
+
return refuse(response, who, 403, "An approver edits what their lead knows about them.", "/settings/lead");
|
|
2151
|
+
const checked = checkAboutYou(body.get("about") ?? "");
|
|
2152
|
+
if (!checked.ok)
|
|
2153
|
+
return redirect(response, chatReturnWithSaid("/settings/lead", checked.message));
|
|
2154
|
+
saveAboutYou(store, who.name, checked.lines, now);
|
|
2155
|
+
return redirect(response, "/settings/lead?saved=about");
|
|
2156
|
+
}
|
|
2157
|
+
// Settings → Lead: stop the lead following up on one of your promises.
|
|
2158
|
+
if (url.pathname === "/settings/lead/promise/cancel") {
|
|
2159
|
+
const body = readForm(posted, CONSOLE_FORMS.leadPromiseCancel);
|
|
2160
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
2161
|
+
return refuse(response, who, 403, "An approver manages the lead's promises.", "/settings/lead");
|
|
2162
|
+
const id = Number(body.get("promise"));
|
|
2163
|
+
const done = Number.isSafeInteger(id) && cancelCommitment(store, who.name, id, who.name, "Cancelled in Settings.", now);
|
|
2164
|
+
// The promise leaving the list says it was cancelled; only a refusal needs words.
|
|
2165
|
+
return redirect(response, done ? "/settings/lead" : chatReturnWithSaid("/settings/lead", "That promise was already closed."));
|
|
2166
|
+
}
|
|
2167
|
+
if (url.pathname === "/settings/lead/on") {
|
|
2168
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
2169
|
+
return refuse(response, who, 403, "An approver turns the lead on.", "/settings/lead");
|
|
2170
|
+
await checkLocalAgents(true);
|
|
2171
|
+
const provider = runtime.localSignIn?.states.claude === "connected" ? "claude-subscription" : runtime.localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
2172
|
+
if (provider === null)
|
|
2173
|
+
return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
|
|
2174
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
|
|
2175
|
+
return redirect(response, "/chat");
|
|
2176
|
+
}
|
|
2177
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
2178
|
+
}
|
|
2179
|
+
/** Settings → Models, read from the saved catalog and the installation's role rows. */
|
|
2180
|
+
function modelsView(canManage, csrf, now, said, problem) {
|
|
2181
|
+
const runtimes = runtimeStates(store);
|
|
2182
|
+
const installed = new Set(runtimes.map(one => one.tool));
|
|
2183
|
+
const openrouter = (readProviderKey("openrouter", providerHome) ?? process.env["OPENROUTER_API_KEY"] ?? "") !== "";
|
|
2184
|
+
const makers = [
|
|
2185
|
+
{ label: "Claude", provider: "claude", on: installed.has("claude") || runtimes.length === 0 },
|
|
2186
|
+
{ label: "Codex", provider: "codex", on: installed.has("codex") },
|
|
2187
|
+
{ label: "Gemini", provider: "gemini", on: installed.has("gemini") },
|
|
2188
|
+
{ label: "OpenRouter", provider: "openrouter", on: openrouter },
|
|
2189
|
+
];
|
|
2190
|
+
const build = store.phaseConfig(INSTALLATION_SCOPE, "build");
|
|
2191
|
+
const roles = ["plan", "build", "review", "repair"].map(phase => {
|
|
2192
|
+
const row = store.phaseConfig(INSTALLATION_SCOPE, phase);
|
|
2193
|
+
const groups = makers.filter(one => one.on && !(phase === "review" && one.provider === "gemini") && !(phase === "repair" && build !== null && one.provider !== build.provider))
|
|
2194
|
+
.map(one => ({ label: one.label, provider: one.provider, options: modelOptions(store, one.provider, now, providerHome) }));
|
|
2195
|
+
return {
|
|
2196
|
+
phase, label: ROLE_TITLES[phase], groups, ...(phase === "repair" ? { inherit: true } : {}),
|
|
2197
|
+
current: row === null ? (phase === "repair" ? "inherit" : "") : `${row.provider}|${row.model ?? ""}`,
|
|
2198
|
+
words: row === null ? (phase === "repair" ? "Same as the builder" : "Not set") : `${ASSISTANTS[row.provider]?.name ?? row.provider} · ${modelWords(store, row.provider, row.model)}`,
|
|
2199
|
+
};
|
|
2200
|
+
});
|
|
2201
|
+
const chat = store.getChatConfig();
|
|
2202
|
+
const chatName = chat === null ? "" : chat.provider === "claude-subscription" ? "Claude membership" : chat.provider === "codex-subscription" ? "Codex membership" : chat.provider === "anthropic-api" ? "Anthropic API" : "OpenRouter";
|
|
2203
|
+
return {
|
|
2204
|
+
runtimes, watch: watchState(store), roles, csrf, canManage, said, problem,
|
|
2205
|
+
chat: chat === null ? null : { words: `Leads and chat use ${chatName} · ${modelWords(store, chat.provider, chat.model)}` },
|
|
2206
|
+
fresh: ["claude", "codex", "gemini"].flatMap(source => seenModels(store, source)).filter(model => isNewModel(model, now)).slice(0, 12),
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
/** Google's redirect back: the code becomes a saved refresh token, and a page sends the person back to Settings. */
|
|
2210
|
+
async function googleCallback(request, response, url) {
|
|
2211
|
+
const done = (said) => {
|
|
2212
|
+
const back = `/settings?said=${encodeURIComponent(said)}#email`;
|
|
2213
|
+
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", "set-cookie": signInSpent(GOOGLE_CALLBACK),
|
|
2214
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" });
|
|
2215
|
+
response.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${escape(back)}"><title>Toolroll</title>${HANDOFF_STYLE}<p>${escape(said)} <a href="${escape(back)}">Back to Settings</a></p>`);
|
|
2216
|
+
};
|
|
2217
|
+
const state = url.searchParams.get("state") ?? "";
|
|
2218
|
+
if (!startedHere(request, state))
|
|
2219
|
+
return done("That Google sign-in was started in another browser. Connect again from this one.");
|
|
2220
|
+
const visit = googleVisits.get(state);
|
|
2221
|
+
googleVisits.delete(state);
|
|
2222
|
+
if (options.configDir === undefined || visit === undefined || visit.expires < Date.now() || store.accountOf(visit.by)?.role !== "approver")
|
|
2223
|
+
return done("That Google sign-in expired. Connect again from Settings.");
|
|
2224
|
+
if (url.searchParams.has("error"))
|
|
2225
|
+
return done(url.searchParams.get("error") === "access_denied" ? "Google wasn't connected: access was declined." : "Google wasn't connected.");
|
|
2226
|
+
const code = url.searchParams.get("code") ?? "";
|
|
2227
|
+
if (!/^[A-Za-z0-9/_.~-]{10,1024}$/.test(code))
|
|
2228
|
+
return done("Google didn't send a sign-in code. Connect again.");
|
|
2229
|
+
if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
|
|
2230
|
+
return respond(response, 403, "text/plain", "Forbidden");
|
|
2231
|
+
const finished = await finishGoogleConsent(options.configDir, visit, code, options.googleFetch ?? fetch);
|
|
2232
|
+
return done(finished.ok ? `Connected ${finished.address}. Send email steps and Email inbox triggers use it now.` : finished.message);
|
|
2233
|
+
}
|
|
2234
|
+
async function connectCallback(request, response, url) {
|
|
2235
|
+
const done = (back, key, words) => {
|
|
2236
|
+
const to = `${back}${back.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(words)}`;
|
|
2237
|
+
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", "set-cookie": signInSpent(CONNECT_CALLBACK),
|
|
2238
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" });
|
|
2239
|
+
response.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${escape(to)}"><title>Toolroll</title>${HANDOFF_STYLE}<p>${escape(words)} <a href="${escape(to)}">Back to Toolroll</a></p>`);
|
|
2240
|
+
};
|
|
2241
|
+
const state = url.searchParams.get("state") ?? "";
|
|
2242
|
+
// Every arrival is logged (never the code or state): a sign-in that never came back is then visible as an absence.
|
|
2243
|
+
logEvent("info", "connect.callback", { error: url.searchParams.get("error"), known: connectVisits.has(state) });
|
|
2244
|
+
if (!startedHere(request, state))
|
|
2245
|
+
return done("/settings/tools", "problem", "That sign-in was started in another browser. Connect again from this one.");
|
|
2246
|
+
const visit = connectVisits.get(state);
|
|
2247
|
+
connectVisits.delete(state);
|
|
2248
|
+
if (visit === undefined || visit.expires < Date.now() || store.accountOf(visit.by)?.role !== "approver")
|
|
2249
|
+
return done("/settings/tools", "problem", "That sign-in expired. Connect again.");
|
|
2250
|
+
const service = oneClickOf(visit.service);
|
|
2251
|
+
const kit = visit.kit === null ? null : kitOf(visit.kit);
|
|
2252
|
+
// Started from a gallery template's page: back to it, for the same project.
|
|
2253
|
+
const template = visit.template === null ? null : galleryTemplateOf(visit.template);
|
|
2254
|
+
const back = template !== null ? `/flows/new/${template.id}?repo=${encodeURIComponent(visit.repo)}`
|
|
2255
|
+
: kit === null ? `/settings/tools?repo=${encodeURIComponent(visit.repo)}` : `/kits/${kit.id}?repo=${encodeURIComponent(visit.repo)}`;
|
|
2256
|
+
const to = projectName(visit.repo);
|
|
2257
|
+
if (url.searchParams.has("error"))
|
|
2258
|
+
return done(back, "problem", url.searchParams.get("error") === "access_denied" ? `${service.label} wasn't connected to ${to}: access was declined.` : `${service.label} wasn't connected to ${to}.`);
|
|
2259
|
+
const code = url.searchParams.get("code") ?? "";
|
|
2260
|
+
if (!/^[\x21-\x7e]{4,2048}$/.test(code))
|
|
2261
|
+
return done(back, "problem", `${service.label} didn't send a sign-in code. Connect again.`);
|
|
2262
|
+
const now = clock();
|
|
2263
|
+
if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
|
|
2264
|
+
return respond(response, 403, "text/plain", "Forbidden");
|
|
2265
|
+
const finished = await finishConnect(store, visit, code, now, { fetcher: options.connectFetch ?? fetch, home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
|
|
2266
|
+
if (!finished.ok)
|
|
2267
|
+
return done(back, "problem", finished.said);
|
|
2268
|
+
// From a kit: its teammate may use the tool now (reading freely, the rest after a person approves each call).
|
|
2269
|
+
const set = kit === null || !kit.tools.some(one => one.tool === service.id) ? null : kitInstalled(store, kit, visit.repo);
|
|
2270
|
+
if (set !== null && store.teammateGrant(set.mate.id, service.id) === null) {
|
|
2271
|
+
const granted = await grantTool(store, set.mate, service.id, visit.by, now, { toolHome });
|
|
2272
|
+
if (granted.ok)
|
|
2273
|
+
return done(back, "said", `${service.label} is connected to ${to}, and ${nameOf(set.mate)} can use it: reading freely, the rest after you approve each call.`);
|
|
2274
|
+
}
|
|
2275
|
+
return done(back, "said", finished.said);
|
|
2276
|
+
}
|
|
2277
|
+
async function edge(ctx) {
|
|
2278
|
+
if (ctx.route.id === 'edge.google-callback')
|
|
2279
|
+
return googleCallback(ctx.request, ctx.response, ctx.url);
|
|
2280
|
+
return connectCallback(ctx.request, ctx.response, ctx.url);
|
|
2281
|
+
}
|
|
2282
|
+
const registrations = [
|
|
2283
|
+
{ id: "edge.google-callback", domain: "settings", stage: "edge", method: "GET", handle: edge },
|
|
2284
|
+
{ id: "edge.connect-callback", domain: "settings", stage: "edge", method: "GET", handle: edge },
|
|
2285
|
+
{ id: "spend", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2286
|
+
{ id: "mode", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2287
|
+
{ id: "control", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2288
|
+
{ id: "control.connection", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2289
|
+
{ id: "settings.models", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2290
|
+
{ id: "settings.tools", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2291
|
+
{ id: "settings.project", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2292
|
+
{ id: "settings.approval", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2293
|
+
{ id: "settings.policy", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2294
|
+
{ id: "settings.integrations", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2295
|
+
{ id: "settings.monitoring", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2296
|
+
{ id: "settings.updates", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2297
|
+
{ id: "settings.retention", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2298
|
+
{ id: "settings.storage", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2299
|
+
{ id: "settings.pull-requests", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2300
|
+
{ id: "settings.checks", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2301
|
+
{ id: "settings.backups", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2302
|
+
{ id: "settings.data", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2303
|
+
{ id: "settings.sign-in", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2304
|
+
{ id: "settings.lead", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2305
|
+
{ id: "settings.teams", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2306
|
+
{ id: "settings.discord", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2307
|
+
{ id: "settings.slack", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2308
|
+
{ id: "settings.slack/manifest", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2309
|
+
{ id: "settings.skills", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2310
|
+
{ id: "settings.flows", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2311
|
+
{ id: "settings.knowledge", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2312
|
+
{ id: "settings.learning", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2313
|
+
{ id: "settings.telegram", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2314
|
+
{ id: "settings.page", domain: "settings", stage: "console", method: "GET", handle: get },
|
|
2315
|
+
{ id: "provider.resume", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2316
|
+
{ id: "settings.updates-dismiss", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2317
|
+
{ id: "settings.updates-checks", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2318
|
+
{ id: "settings.skills-revise", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2319
|
+
{ id: "settings.skills-import", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2320
|
+
{ id: "settings.skills-change", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2321
|
+
{ id: "spend.budget", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2322
|
+
{ id: "settings.project-concurrency", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2323
|
+
{ id: "settings.project-delete", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2324
|
+
{ id: "settings.policy-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2325
|
+
{ id: "settings.approval-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2326
|
+
{ id: "settings.request-limits-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2327
|
+
{ id: "settings.sign-in-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2328
|
+
{ id: "settings.updates-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2329
|
+
{ id: "settings.updates-seen-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2330
|
+
{ id: "settings.updates-cancel-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2331
|
+
{ id: "settings.retention-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2332
|
+
{ id: "settings.storage-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2333
|
+
{ id: "settings.storage-clean-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2334
|
+
{ id: "settings.storage-discard-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2335
|
+
{ id: "settings.pull-requests-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2336
|
+
{ id: "settings.checks-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2337
|
+
{ id: "settings.backups-now-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2338
|
+
{ id: "settings.backups-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2339
|
+
{ id: "settings.data-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2340
|
+
{ id: "settings.integrations-test-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2341
|
+
{ id: "settings.monitoring-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2342
|
+
{ id: "settings.tools-connect-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2343
|
+
{ id: "settings.tools-change-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2344
|
+
{ id: "settings.appearance-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2345
|
+
{ id: "settings.flows-on", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2346
|
+
{ id: "settings.models-act", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2347
|
+
{ id: "settings.knowledge-refresh", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2348
|
+
{ id: "settings.knowledge-proposal", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2349
|
+
{ id: "settings.knowledge-decision", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2350
|
+
{ id: "settings.knowledge-change", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2351
|
+
{ id: "settings.learning-change", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2352
|
+
{ id: "control.setup-preview", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2353
|
+
{ id: "control.setup-approve", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2354
|
+
{ id: "control.instructions-preview", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2355
|
+
{ id: "control.instructions-approve", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2356
|
+
{ id: "settings.slack-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2357
|
+
{ id: "settings.teams-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2358
|
+
{ id: "settings.discord-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2359
|
+
{ id: "settings.telegram-retry", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2360
|
+
{ id: "settings.chat-approval-confirm", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2361
|
+
{ id: "settings.chat-approval-save", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2362
|
+
{ id: "settings.chat-approval-off", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2363
|
+
{ id: "settings.telegram-pair", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2364
|
+
{ id: "settings.telegram-unpair", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2365
|
+
{ id: "settings.messaging-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2366
|
+
{ id: "settings.permission-default-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2367
|
+
{ id: "settings.quality-default-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2368
|
+
{ id: "settings.notifications-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2369
|
+
{ id: "settings.notifications-mute-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2370
|
+
{ id: "settings.telegram-digest-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2371
|
+
{ id: "settings.provider-key-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2372
|
+
{ id: "settings.provider-key-clear-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2373
|
+
{ id: "settings.telegram-token-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2374
|
+
{ id: "settings.email-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2375
|
+
{ id: "settings.email-test-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2376
|
+
{ id: "settings.email-read-test-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2377
|
+
{ id: "settings.google-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2378
|
+
{ id: "settings.google-disconnect-send", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2379
|
+
{ id: "mode.confirm", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2380
|
+
{ id: "mode.sign", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2381
|
+
{ id: "mode.revoke", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2382
|
+
{ id: "settings.lead-identity", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2383
|
+
{ id: "settings.lead-about", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2384
|
+
{ id: "settings.lead-promise-cancel", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2385
|
+
{ id: "settings.lead-on", domain: "settings", stage: "console", method: "POST", handle: post },
|
|
2386
|
+
];
|
|
2387
|
+
return { registrations, get, post, modelsView };
|
|
2388
|
+
}
|