toolroll 0.9.89 → 0.9.90
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 +44 -0
- package/README.md +16 -10
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent-board.d.ts +9 -0
- package/dist/agent-board.js +20 -0
- package/dist/agent-fence.js +20 -2
- package/dist/approval-authority.d.ts +7 -0
- package/dist/approval-authority.js +13 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +13 -0
- package/dist/builder.js +5 -2
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +7 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-task-actions.d.ts +1 -0
- package/dist/check-access.d.ts +3 -0
- package/dist/check-access.js +4 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +6 -2
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +23 -0
- package/dist/coding-ui.js +4 -2
- package/dist/connect-cli.js +2 -1
- package/dist/console-instance.d.ts +34 -1
- package/dist/console-instance.js +124 -15
- package/dist/contracts/cli.d.ts +72 -0
- package/dist/contracts/cli.js +13 -0
- package/dist/contracts/console-api.d.ts +9 -2
- package/dist/contracts/console-api.js +9 -2
- package/dist/desktop-host.js +8 -3
- package/dist/desktop-update.js +2 -1
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +22 -2
- package/dist/easy-start-args.d.ts +13 -0
- package/dist/easy-start-args.js +24 -0
- package/dist/easy-start.d.ts +77 -0
- package/dist/easy-start.js +321 -0
- package/dist/first-run.d.ts +77 -0
- package/dist/first-run.js +45 -0
- package/dist/guides.js +1 -1
- package/dist/invoke.js +11 -6
- package/dist/laptop-background.d.ts +171 -0
- package/dist/laptop-background.js +550 -0
- package/dist/laptop-service.d.ts +111 -0
- package/dist/laptop-service.js +268 -0
- package/dist/lead-cli.js +2 -1
- package/dist/lead-doors.d.ts +6 -0
- package/dist/lead-doors.js +20 -2
- package/dist/live-records.d.ts +1 -1
- package/dist/live-records.js +3 -12
- package/dist/local-owner.d.ts +124 -0
- package/dist/local-owner.js +212 -0
- package/dist/login-secrets.d.ts +60 -0
- package/dist/login-secrets.js +195 -0
- package/dist/mcp-oauth.d.ts +4 -0
- package/dist/mcp-oauth.js +13 -7
- package/dist/model-choices.d.ts +43 -0
- package/dist/model-choices.js +80 -0
- package/dist/operate.d.ts +46 -1
- package/dist/operate.js +461 -40
- package/dist/person-proof.js +3 -0
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/quick-start.d.ts +3 -1
- package/dist/quick-start.js +6 -1
- package/dist/release-cli.js +2 -1
- package/dist/runner-process.d.ts +9 -0
- package/dist/runner-process.js +18 -0
- package/dist/sample-project.d.ts +31 -0
- package/dist/sample-project.js +141 -0
- package/dist/scope.d.ts +24 -2
- package/dist/scope.js +74 -3
- package/dist/serve.js +129 -22
- package/dist/server/chat.js +10 -3
- package/dist/server/chrome.d.ts +10 -2
- package/dist/server/chrome.js +7 -5
- package/dist/server/console.css +10 -0
- package/dist/server/guards.d.ts +3 -0
- package/dist/server/guards.js +22 -2
- package/dist/server/http.d.ts +22 -0
- package/dist/server/http.js +58 -0
- package/dist/server/live.js +4 -14
- package/dist/server/mutation-key.d.ts +70 -0
- package/dist/server/mutation-key.js +142 -0
- package/dist/server/pages.js +10 -7
- package/dist/server/people-tokens.js +240 -20
- package/dist/server/remote.d.ts +2 -0
- package/dist/server/remote.js +19 -4
- package/dist/server/render-new-task.js +5 -1
- package/dist/server/render-pages.d.ts +2 -1
- package/dist/server/render-pages.js +1 -1
- package/dist/server/render-people.d.ts +36 -0
- package/dist/server/render-people.js +65 -1
- package/dist/server/render-projects.js +4 -2
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +12 -4
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.d.ts +16 -0
- package/dist/server/route-table.js +121 -0
- package/dist/server/runtime.d.ts +6 -4
- package/dist/server/session.d.ts +31 -1
- package/dist/server/session.js +12 -2
- package/dist/server/settings.js +63 -8
- package/dist/server/task-views.js +10 -1
- package/dist/server/tasks.d.ts +10 -0
- package/dist/server/tasks.js +59 -9
- package/dist/server/terminals.js +15 -2
- package/dist/server/welcome.d.ts +22 -16
- package/dist/server/welcome.js +150 -52
- package/dist/settings-groups-ui.d.ts +7 -1
- package/dist/settings-groups-ui.js +23 -2
- package/dist/settings-proofs.js +1 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +124 -1
- package/dist/store.js +245 -12
- package/dist/surface.d.ts +10 -0
- package/dist/surface.js +40 -1
- package/dist/task-text.d.ts +6 -0
- package/dist/task-text.js +17 -0
- package/dist/telegram-lead.js +2 -0
- package/dist/terminal-text.d.ts +11 -0
- package/dist/terminal-text.js +26 -0
- package/dist/toolroll-update.d.ts +8 -1
- package/dist/toolroll-update.js +151 -4
- package/dist/tui-contract.d.ts +118 -0
- package/dist/tui-contract.js +9 -0
- package/dist/tui-source.d.ts +14 -0
- package/dist/tui-source.js +182 -0
- package/dist/tui.js +129 -0
- package/dist/up-processes.d.ts +13 -1
- package/dist/up-processes.js +13 -3
- package/dist/welcome-facts.d.ts +18 -0
- package/dist/welcome-facts.js +79 -0
- package/package.json +4 -2
package/dist/serve.js
CHANGED
|
@@ -154,19 +154,22 @@ import { createPagesHandlers } from './server/pages.js';
|
|
|
154
154
|
import { createPeopleHandlers } from './server/people-tokens.js';
|
|
155
155
|
import { createSettingsHandlers } from './server/settings.js';
|
|
156
156
|
import { requestContext } from "./server/request-context.js";
|
|
157
|
+
import { asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
|
|
158
|
+
import { defaultAgentOf, fillDefaultRouting } from "./model-choices.js";
|
|
157
159
|
import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
|
|
158
|
-
import { BODY_CAP, form, loginHref, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
|
|
160
|
+
import { BODY_CAP, form, loginHref, passwordSetNext, refusalNext, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
|
|
159
161
|
import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, NOT_STARTED_SESSION, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
|
|
160
162
|
import {} from "./server/render-pages.js";
|
|
161
|
-
import { serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
|
|
163
|
+
import { localOwnerStepUps, serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
|
|
162
164
|
import { NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, PersistentSessions, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, sessionWithinLifetime, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
|
|
163
165
|
import { createTasksHandlers } from './server/tasks.js';
|
|
164
166
|
import { KIT_BEHAVIOR_MARKUP, KIT_STANDALONE_SCRIPT } from './server/kit-standalone.js';
|
|
165
167
|
import { createWelcomeHandlers } from './server/welcome.js';
|
|
168
|
+
import { createBrowserKeys, KEY_FILL_SCRIPT, pagePathOf } from './server/mutation-key.js';
|
|
166
169
|
import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
|
|
167
170
|
import { sourceKey } from "./source-key.js";
|
|
168
171
|
import { readSsoSettings } from "./sso-settings.js";
|
|
169
|
-
import { LEAD_THREAD } from "./store.js";
|
|
172
|
+
import { LEAD_THREAD, localOwnerBinding } from "./store.js";
|
|
170
173
|
import { waitingUpdate } from "./toolroll-update.js";
|
|
171
174
|
import { PACKAGE_VERSION } from "./version.js";
|
|
172
175
|
import { loadConsoleUrl } from "./webhooks.js";
|
|
@@ -211,6 +214,7 @@ export function createDecisionServer(options) {
|
|
|
211
214
|
get signInBudget() { return signInBudget; },
|
|
212
215
|
get passwordAllowed() { return passwordAllowed; },
|
|
213
216
|
get sessions() { return sessions; },
|
|
217
|
+
get browserKeys() { return browserKeys; },
|
|
214
218
|
});
|
|
215
219
|
const { store, evidenceRoot } = options;
|
|
216
220
|
let coding = options.codingWorkspace ?? null;
|
|
@@ -363,17 +367,13 @@ export function createDecisionServer(options) {
|
|
|
363
367
|
const linkKey = (code) => createHash("sha256").update(code).digest("hex");
|
|
364
368
|
function mintSignInLink(account) {
|
|
365
369
|
const known = store.accountOf(account);
|
|
366
|
-
|
|
370
|
+
// A full link is for an account with a password: a local owner without one gets only mintLocalLink's.
|
|
371
|
+
if (known === null || known.role !== "approver" || known.revokedAt !== null || !known.passwordSet)
|
|
367
372
|
return null;
|
|
368
|
-
|
|
369
|
-
if (one.expires < Date.now())
|
|
370
|
-
signInLinks.delete(key);
|
|
371
|
-
while (signInLinks.size >= 20)
|
|
372
|
-
signInLinks.delete(signInLinks.keys().next().value);
|
|
373
|
-
const code = randomBytes(32).toString("base64url");
|
|
374
|
-
signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
375
|
-
return `${SIGN_IN_LINK_PATH}${code}`;
|
|
373
|
+
return holdSignInLink({ kind: "full", account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
376
374
|
}
|
|
375
|
+
/** v147: this console start's key epoch (easy-start C2): the browser-key wave binds each session's mutation key to it. */
|
|
376
|
+
const keyEpoch = options.keyEpoch ?? randomBytes(16).toString("hex");
|
|
377
377
|
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
|
|
378
378
|
* any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. */
|
|
379
379
|
const fromThisComputer = (request) => {
|
|
@@ -754,6 +754,20 @@ export function createDecisionServer(options) {
|
|
|
754
754
|
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
|
|
755
755
|
return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
|
|
756
756
|
};
|
|
757
|
+
/** Easy-start C2: a protected loopback install (bound to loopback only, no --allow-host, no public URL, no tailnet
|
|
758
|
+
* names) has one browser host, and there every browser mutation also presents the session's key (mutation-key.ts). */
|
|
759
|
+
const browserKeys = createBrowserKeys({
|
|
760
|
+
enabled: options.browserKey === true,
|
|
761
|
+
epoch: keyEpoch,
|
|
762
|
+
protectedHost: () => {
|
|
763
|
+
const address = server.address(), port = servedPort();
|
|
764
|
+
if (typeof address !== "object" || address === null || port === null)
|
|
765
|
+
return null;
|
|
766
|
+
if ((options.allowedHosts ?? []).length > 0 || publicOrigin !== null || tailnet.length > 0)
|
|
767
|
+
return null;
|
|
768
|
+
return address.address === "127.0.0.1" ? `127.0.0.1:${port}` : address.address === "::1" ? `[::1]:${port}` : null;
|
|
769
|
+
},
|
|
770
|
+
});
|
|
757
771
|
/** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
|
|
758
772
|
const consoleCommand = (change) => {
|
|
759
773
|
const port = servedPort() ?? 4180;
|
|
@@ -824,6 +838,17 @@ export function createDecisionServer(options) {
|
|
|
824
838
|
if (!allowedHost(request.headers.host)) {
|
|
825
839
|
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
826
840
|
}
|
|
841
|
+
// Easy-start C2: a protected loopback install has one browser origin, whose storage holds the session's mutation key.
|
|
842
|
+
// A browser's page read at another loopback name (localhost, [::1]) moves there; machine callers and protocol
|
|
843
|
+
// adapters (an API token, MCP's own addresses) are left where they are.
|
|
844
|
+
const canonical = browserKeys.canonicalHost();
|
|
845
|
+
if (canonical !== null && request.headers.host !== canonical && (method === "GET" || method === "HEAD") && request.headers.authorization === undefined
|
|
846
|
+
&& (edgeRoute === null || edgeRoute.domain === "people")) {
|
|
847
|
+
request.resume();
|
|
848
|
+
response.writeHead(308, { location: `http://${canonical}${hook.pathname}${hook.search}`, "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
|
|
849
|
+
response.end();
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
827
852
|
// A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
|
|
828
853
|
// plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
|
|
829
854
|
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
|
|
@@ -915,6 +940,13 @@ export function createDecisionServer(options) {
|
|
|
915
940
|
...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
|
|
916
941
|
workspaceRequest: url.searchParams.get('request'),
|
|
917
942
|
timeline,
|
|
943
|
+
// Easy-start C2: every page that posts carries the key fill while the key is required; a session whose key is from
|
|
944
|
+
// an earlier start (or has none) is told to reconnect.
|
|
945
|
+
keyFill: browserKeys.required(),
|
|
946
|
+
// Easy start: a local owner with no password sees "Set a password to do this." where a step-up field would be.
|
|
947
|
+
...(who.via === "cookie" && who.authority === "local-owner" && store.accountOf(who.name)?.passwordSet === false
|
|
948
|
+
? { localStepUp: localOwnerRefusal(false, method === "GET" ? passwordSetNext(pagePathOf(url)) : "/").href } : {}),
|
|
949
|
+
reconnect: browserKeys.stale(who, method === "GET" ? safeReturn(pagePathOf(url)) : "/"),
|
|
918
950
|
};
|
|
919
951
|
response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
|
|
920
952
|
if (method === "GET" || method === "POST")
|
|
@@ -932,6 +964,12 @@ export function createDecisionServer(options) {
|
|
|
932
964
|
return restricted()
|
|
933
965
|
? refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects")
|
|
934
966
|
: refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
967
|
+
// Easy-start C1: a local owner's session reaches only the rows marked for it (route-table.ts `local`), decided
|
|
968
|
+
// first, before the route's policy reads anything and before any handler runs, GET and POST alike.
|
|
969
|
+
if (who.via === "cookie" && who.authority !== "full" && (route.local !== "allow" || localPrincipalOf(who, route) === null)) {
|
|
970
|
+
request.resume();
|
|
971
|
+
return refuseLocalOwner(response, who, method, url, request.headers.accept);
|
|
972
|
+
}
|
|
935
973
|
const policy = evaluateRoutePolicy(route, { caller: who.via, capability: who.via === "bearer" && who.principal !== undefined ? who.principal.scope : who.role === "approver" ? "act" : "read", viewer: who.role === "viewer", token: who.via === "bearer" && who.principal !== undefined }, source => projectRequestAllowed(route, source, url, who, request, response, body));
|
|
936
974
|
if (!policy.ok) {
|
|
937
975
|
if (policy.reason === "project")
|
|
@@ -984,14 +1022,20 @@ export function createDecisionServer(options) {
|
|
|
984
1022
|
}
|
|
985
1023
|
return handlePost(route, url, who, request, response, body);
|
|
986
1024
|
};
|
|
1025
|
+
// Easy-start C1: an allowed row runs as the local owner, in the act the row is (execute refuses every other): the
|
|
1026
|
+
// prover answers "local-owner" only for that act, and every write it makes re-proves it still stands.
|
|
1027
|
+
const local = who.via === "cookie" && who.authority !== "full" && matchedRoute?.stage === "console" && matchedRoute.local === "allow" ? localPrincipalOf(who, matchedRoute) : null;
|
|
1028
|
+
if (local !== null)
|
|
1029
|
+
response.once("close", () => { local.request.ended = true; });
|
|
1030
|
+
const run = () => local === null ? execute() : asLocalOwner(local, execute);
|
|
987
1031
|
if (method === "GET")
|
|
988
|
-
return projectAuthority.run({ actor: who.name, repo: target.repo },
|
|
1032
|
+
return projectAuthority.run({ actor: who.name, repo: target.repo }, run);
|
|
989
1033
|
// Request acceptance is separate from durable work completion. Never
|
|
990
1034
|
// persist a body, query string, token, password, or arbitrary URL.
|
|
991
1035
|
const entry = { at: clock().toISOString(), actor: who.name, ...target, source: "request", ...(who.via === "bearer" && who.token !== undefined ? { detail: `API token: ${who.token}` } : {}) };
|
|
992
1036
|
store.recordAction({ ...entry, outcome: "requested" });
|
|
993
1037
|
try {
|
|
994
|
-
await projectAuthority.run({ actor: who.name, repo: target.repo },
|
|
1038
|
+
await projectAuthority.run({ actor: who.name, repo: target.repo }, run);
|
|
995
1039
|
const createdTask = requestContext.getStore()?.createdTask;
|
|
996
1040
|
const placement = createdTask === undefined ? null : store.lookupRef(createdTask);
|
|
997
1041
|
store.recordAction({ ...entry, ...(placement === null ? {} : { repo: placement.repo, taskId: placement.externalId }), at: clock().toISOString(), outcome: response.statusCode >= 500 ? "error" : response.statusCode >= 400 ? "refused" : "accepted" });
|
|
@@ -1195,8 +1239,15 @@ export function createDecisionServer(options) {
|
|
|
1195
1239
|
if (provider === null)
|
|
1196
1240
|
return;
|
|
1197
1241
|
const now = clock();
|
|
1198
|
-
store.
|
|
1199
|
-
|
|
1242
|
+
store.transact(() => {
|
|
1243
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
|
|
1244
|
+
store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
|
|
1245
|
+
// A laptop install (local mode on): the agent chosen here also starts the models, on its default preset, while
|
|
1246
|
+
// nothing is chosen yet, so the owner's first task plans without a password (model-choices.ts fillDefaultRouting).
|
|
1247
|
+
const agent = defaultAgentOf(provider);
|
|
1248
|
+
if (agent !== null && localModeOn(store))
|
|
1249
|
+
fillDefaultRouting(store, agent, "toolroll", { kind: "first-start" }, now);
|
|
1250
|
+
});
|
|
1200
1251
|
}
|
|
1201
1252
|
// Look once at start, so the lead is on by the time the browser opens.
|
|
1202
1253
|
if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
|
|
@@ -1517,9 +1568,16 @@ export function createDecisionServer(options) {
|
|
|
1517
1568
|
if (s.workspace?.view?.kind === "flow")
|
|
1518
1569
|
s = { ...s, workspace: { ...s.workspace, view: { ...s.workspace.view, stepUp: { label: ssoFacts.label, fresh: ssoFacts.fresh, confirmHref: `/login/sso?reauth=1&return=${encodeURIComponent(back)}` } } } };
|
|
1519
1570
|
}
|
|
1571
|
+
// Easy start: a local owner with no password has none to type: each step-up field says how to set one instead.
|
|
1572
|
+
const localStepUp = requestContext.getStore()?.localStepUp;
|
|
1573
|
+
if (localStepUp !== undefined) {
|
|
1574
|
+
s = { ...s, body: localOwnerStepUps(s.body, localStepUp), ...(s.workspace?.pageHtml == null ? {} : { workspace: { ...s.workspace, pageHtml: localOwnerStepUps(s.workspace.pageHtml, localStepUp) } }) };
|
|
1575
|
+
}
|
|
1520
1576
|
// D5: team chat (the central team service) is deprecated; /chat?team=1 still opens this release, but nothing links to it.
|
|
1521
1577
|
const sensitive = authored || s.forceSensitive === true;
|
|
1522
1578
|
const requestFacts = requestContext.getStore();
|
|
1579
|
+
// Easy-start C2: while the browser mutation key is required, every page's one nonce'd script also fills the key.
|
|
1580
|
+
const keyFill = requestFacts?.keyFill === true ? KEY_FILL_SCRIPT : "";
|
|
1523
1581
|
// The settings frame (settings-kit.ts): its breadcrumb, for the shell and the workspace alike.
|
|
1524
1582
|
if (s.chrome !== undefined && s.chrome.crumbs === undefined) {
|
|
1525
1583
|
const crumbs = settingsCrumbs(new URL(requestFacts?.returnTo ?? '/', 'http://standing-orders.local').pathname, s.title, htmlString(s.body));
|
|
@@ -1668,6 +1726,8 @@ export function createDecisionServer(options) {
|
|
|
1668
1726
|
...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
|
|
1669
1727
|
...(extras.phone === undefined ? {} : { phone: extras.phone }),
|
|
1670
1728
|
...(extras.home == null ? {} : { home: extras.home }),
|
|
1729
|
+
// Easy-start C2: this tab's key is from an earlier start (or it has none): one way to reconnect.
|
|
1730
|
+
...(requestFacts.reconnect == null ? {} : { reconnect: requestFacts.reconnect }),
|
|
1671
1731
|
};
|
|
1672
1732
|
if (requestFacts.workspaceRead) {
|
|
1673
1733
|
const validator = requestFacts.workspaceValidator;
|
|
@@ -1693,13 +1753,18 @@ export function createDecisionServer(options) {
|
|
|
1693
1753
|
// A page with its own view keeps its script; the chat page runs the result script.
|
|
1694
1754
|
const functional = conversation === null || extras.view ? (s.functional?.script ?? '') : RESULT_REVIEW_SCRIPT;
|
|
1695
1755
|
const document = shell(s.title, s.body, { chrome: s.chrome, sensitive });
|
|
1696
|
-
return page(response, status, browserWorkspaceDocument(document, workspace, nonce, functional + MOBILE_VIEWPORT_SCRIPT), nonce, true);
|
|
1756
|
+
return page(response, status, browserWorkspaceDocument(document, workspace, nonce, functional + MOBILE_VIEWPORT_SCRIPT + keyFill), nonce, true);
|
|
1697
1757
|
}
|
|
1698
1758
|
// A one-time secret on screen (a token, an invite link, a pairing code): the
|
|
1699
1759
|
// page carries no script of any kind, so it leaves the workspace for a
|
|
1700
1760
|
// focused page in the same look: the brand, the one card, and the way back.
|
|
1701
|
-
|
|
1702
|
-
|
|
1761
|
+
// Easy-start C2: but for the key fill when it has a form that posts (the fill reads nothing on the page).
|
|
1762
|
+
if (s.forceSensitive === true) {
|
|
1763
|
+
if (keyFill === "" || !/<form\b[^>]*method="post"/i.test(htmlString(s.body)))
|
|
1764
|
+
return page(response, status, focusDocument(s.title, s.body));
|
|
1765
|
+
const nonce = randomBytes(16).toString("base64");
|
|
1766
|
+
return page(response, status, focusDocument(s.title, s.body, { nonce, script: keyFill }), nonce);
|
|
1767
|
+
}
|
|
1703
1768
|
const chromeLayer = !sensitive && s.chrome !== undefined;
|
|
1704
1769
|
const functional = s.functional?.script ?? "";
|
|
1705
1770
|
// Sensitive pages strip the palette and keys but keep the sidebar toggle.
|
|
@@ -1707,7 +1772,7 @@ export function createDecisionServer(options) {
|
|
|
1707
1772
|
// Kit markup the workspace bundle would have wired (a composer, an overlay, a switch, an idea) gets the kit's own small
|
|
1708
1773
|
// behaviour here (kit-standalone.ts); never on a sensitive page, which carries no chrome additions.
|
|
1709
1774
|
const kitBehavior = chromeLayer && KIT_BEHAVIOR_MARKUP.test(htmlString(s.body)) ? KIT_STANDALONE_SCRIPT : "";
|
|
1710
|
-
const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome);
|
|
1775
|
+
const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome) + keyFill;
|
|
1711
1776
|
const nonce = script === "" ? undefined : randomBytes(16).toString("base64");
|
|
1712
1777
|
const body = chromeLayer
|
|
1713
1778
|
? html `${s.body}\n${paletteTagCached(s.chrome?.project ?? null)}\n${KBD_HELP}`
|
|
@@ -1972,7 +2037,7 @@ export function createDecisionServer(options) {
|
|
|
1972
2037
|
// and role were admitted by the route table.
|
|
1973
2038
|
const denied = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
|
|
1974
2039
|
if (denied !== null)
|
|
1975
|
-
return refuse(response, who, denied.status, denied.message);
|
|
2040
|
+
return refuse(response, who, denied.status, denied.message, "/", denied.reconnect?.kind === "password" ? { label: "Reconnect", href: denied.reconnect.href } : undefined);
|
|
1976
2041
|
// Standing can change after a token was minted: a viewer's act stays refused, cookie and bearer alike, except
|
|
1977
2042
|
// the row's session-local acts.
|
|
1978
2043
|
if (who.role === "viewer" && !route.viewer)
|
|
@@ -2306,6 +2371,8 @@ export function createDecisionServer(options) {
|
|
|
2306
2371
|
get firstTasks() { return firstTasks; },
|
|
2307
2372
|
get authenticateCredential() { return authenticateCredential; },
|
|
2308
2373
|
get identities() { return identities; },
|
|
2374
|
+
get keyEpoch() { return keyEpoch; },
|
|
2375
|
+
get browserKeys() { return browserKeys; },
|
|
2309
2376
|
};
|
|
2310
2377
|
const tasksHandlers = createTasksHandlers(handlerRuntime);
|
|
2311
2378
|
const pagesHandlers = createPagesHandlers(handlerRuntime);
|
|
@@ -2315,6 +2382,7 @@ export function createDecisionServer(options) {
|
|
|
2315
2382
|
const peopleHandlers = createPeopleHandlers(handlerRuntime);
|
|
2316
2383
|
const remoteRuntime = {
|
|
2317
2384
|
get identify() { return identify; },
|
|
2385
|
+
get browserKeys() { return browserKeys; },
|
|
2318
2386
|
get freshFactsOf() { return freshFactsOf; },
|
|
2319
2387
|
get store() { return store; },
|
|
2320
2388
|
get admitPasswordSource() { return admitPasswordSource; },
|
|
@@ -2373,7 +2441,46 @@ export function createDecisionServer(options) {
|
|
|
2373
2441
|
}
|
|
2374
2442
|
return withEdgePolicy(route, () => registry.edge(route, { ...ctx, route }));
|
|
2375
2443
|
}
|
|
2376
|
-
|
|
2444
|
+
/** Easy-start C1: the local owner a browser session acts as on an allowed row, bound to its session's saved row and
|
|
2445
|
+
* the generations it was made under; null when it no longer stands (refused like any other row). */
|
|
2446
|
+
function localPrincipalOf(who, route) {
|
|
2447
|
+
const sessionHash = sessions.hashOf(who.session);
|
|
2448
|
+
if (route.localAct === null || who.session.localGeneration === null || sessionHash === null)
|
|
2449
|
+
return null;
|
|
2450
|
+
const principal = { account: who.name, localGeneration: who.session.localGeneration, accountGeneration: who.session.generation,
|
|
2451
|
+
transport: "browser", act: route.localAct, sessionHash, request: { ended: false } };
|
|
2452
|
+
return localOwnerStandsNow(store, principal) ? principal : null;
|
|
2453
|
+
}
|
|
2454
|
+
/** What a local owner hears on a row not marked for it: set a first password, or sign in with the one set. */
|
|
2455
|
+
function refuseLocalOwner(response, who, method, url, accept) {
|
|
2456
|
+
// The page it was refused on, built here from the request (never from a header): a form's nearest page above it.
|
|
2457
|
+
const words = localOwnerRefusal(store.accountOf(who.name)?.passwordSet === true, refusalNext(method, url));
|
|
2458
|
+
// Which refusal this was, for the shell (it offers the same way on) and the route inventory's rule table.
|
|
2459
|
+
response.setHeader("x-toolroll-refused", "local-owner");
|
|
2460
|
+
if (String(accept ?? "").includes("application/json"))
|
|
2461
|
+
return respond(response, 403, "application/json", JSON.stringify({ ok: false, said: words.message, href: words.href }));
|
|
2462
|
+
return refuse(response, who, 403, words.message, words.href, { label: words.action, href: words.href });
|
|
2463
|
+
}
|
|
2464
|
+
// Defined last, so the reviewed functions above keep their places (password-freshness.test.ts).
|
|
2465
|
+
function holdSignInLink(link) {
|
|
2466
|
+
for (const [key, one] of signInLinks)
|
|
2467
|
+
if (one.expires < Date.now())
|
|
2468
|
+
signInLinks.delete(key);
|
|
2469
|
+
while (signInLinks.size >= 20)
|
|
2470
|
+
signInLinks.delete(signInLinks.keys().next().value);
|
|
2471
|
+
const code = randomBytes(32).toString("base64url");
|
|
2472
|
+
signInLinks.set(linkKey(code), link);
|
|
2473
|
+
return `${SIGN_IN_LINK_PATH}${code}`;
|
|
2474
|
+
}
|
|
2475
|
+
/** Easy-start C4: a local owner's one-time link. It names no account: local mode's own owner, read now, while local
|
|
2476
|
+
* mode is on and the owner is active. Redemption reads local mode again and issues only a local-owner session. */
|
|
2477
|
+
function mintLocalLink() {
|
|
2478
|
+
const binding = localOwnerBinding(store);
|
|
2479
|
+
if (binding === null)
|
|
2480
|
+
return null;
|
|
2481
|
+
return holdSignInLink({ kind: "local-owner", ...binding, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
2482
|
+
}
|
|
2483
|
+
return Object.assign(server, { mintSignInLink, mintLocalLink, keyEpoch, closeCoding });
|
|
2377
2484
|
}
|
|
2378
2485
|
export { PAGE_CSS, pinnedTheme, SENSITIVE_INPUT } from "./server/chrome.js";
|
|
2379
2486
|
export { redactedPath, SHUTDOWN_WAIT_MS } from "./server/http.js";
|
package/dist/server/chat.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { freshFacts, freshlyApprovable, freshnessFor, spendingOnlyOnSuccess, withFreshness } from "../scope.js";
|
|
2
|
+
import { CHAT_ACTION_LOCAL_ACTS, localOwnerRefusal } from "../local-owner.js";
|
|
3
|
+
import { localApprovalEligible } from "../scope.js";
|
|
2
4
|
import { handlersOf } from './handler-registry.js';
|
|
3
5
|
import { DEPRECATED_PAGE } from "../deprecations.js";
|
|
4
6
|
/** chat handlers, moved without changing their route bodies. */
|
|
@@ -410,7 +412,12 @@ export function createChatHandlers(runtime) {
|
|
|
410
412
|
const gate = store.approvalGate(task, who.name, 'person');
|
|
411
413
|
return freshlyApprovable(store, task, scope, gate.verdict === 'seal' && !gate.protectedWork) ? new Date(fresh.at) : null;
|
|
412
414
|
})();
|
|
413
|
-
|
|
415
|
+
// Easy-start C5: a laptop's local owner confirms its own acts without a password (an approval only when it is
|
|
416
|
+
// eligible: localApprovalEligible, the rule the seal applies again); anything else says how to get a password.
|
|
417
|
+
const reviewTask = typeof action.request['task'] === 'string' ? action.request['task'] : null;
|
|
418
|
+
const localOk = who.authority === 'local-owner' && CHAT_ACTION_LOCAL_ACTS[action.operation] !== null && (action.operation !== 'scope_approve' || (reviewTask !== null && localApprovalEligible(store, who.name, reviewTask).ok));
|
|
419
|
+
const localWords = who.authority === 'local-owner' && !localOk ? localOwnerRefusal(store.accountOf(who.name)?.passwordSet === true, '/chat') : null;
|
|
420
|
+
const passwordField = !CHAT_ACTIONS[action.operation].password || localOk ? '' : localWords !== null ? html `<p class="approval-password-note">${localWords.message} <a href="${localWords.href}">${localWords.action}</a></p>` : reauth(freshSince === null
|
|
414
421
|
? html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`
|
|
415
422
|
: html `<label>Your password<input type="password" name="token" autocomplete="current-password"></label><p class="approval-password-note" data-approval-fresh>Your password from ${whenTime(freshSince.toISOString())} is remembered on this computer — leave this blank to approve with it, or type it again.</p>`);
|
|
416
423
|
const content = html `<section class="shared-action"><h1>${action.title}</h1>${evidenceLink}${terms}${postForm(`/chat/proposal/${id}/confirm`, html `<label class="arm"><input type="checkbox" name="confirm" value="yes" required>I confirm this exact action</label>${passwordField}<button>${CHAT_ACTIONS[action.operation].label}</button>`, { returnTo: '/chat', hidden: { nonce: review.nonce } })}</section>`;
|
|
@@ -781,7 +788,7 @@ export function createChatHandlers(runtime) {
|
|
|
781
788
|
}
|
|
782
789
|
// A scope approval's re-check is spent only if the whole confirmation commits (one transaction: the proposal's
|
|
783
790
|
// settlement included). Any other action spends it whatever comes of it (its effects aren't all in the database).
|
|
784
|
-
const confirmIt = () => confirmLeadProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
|
|
791
|
+
const confirmIt = () => confirmLeadProposal(store, principal, id, now, { authority: who.authority, chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
|
|
785
792
|
const proposed = store.getLeadProposal(id);
|
|
786
793
|
const approving = proposed?.kind === "action" && sharedActionPayload(proposed.payload)?.operation === "scope_approve";
|
|
787
794
|
const outcome = approving ? spendingOnlyOnSuccess(confirmIt, result => !result.ok || ("already" in result && result.already === true)) : confirmIt();
|
|
@@ -847,7 +854,7 @@ export function createChatHandlers(runtime) {
|
|
|
847
854
|
dismissCoordinatorProposal(store, principal, id, now);
|
|
848
855
|
return redirect(response, back);
|
|
849
856
|
}
|
|
850
|
-
const outcome = confirmCoordinatorProposal(store, principal, id, now, { confirm: body.get("confirm") === "yes", via: "web" });
|
|
857
|
+
const outcome = confirmCoordinatorProposal(store, principal, id, now, { authority: who.authority, confirm: body.get("confirm") === "yes", via: "web" });
|
|
851
858
|
if (!outcome.ok && (outcome.reason === "not-yours" || outcome.reason === "standing")) {
|
|
852
859
|
return refuse(response, who, outcome.reason === "standing" ? 403 : 404, outcome.said, back);
|
|
853
860
|
}
|
package/dist/server/chrome.d.ts
CHANGED
|
@@ -21,7 +21,12 @@ export declare function isOverdue(decision: Decision, now: Date): boolean;
|
|
|
21
21
|
* a way back — the error path is the one place a console must not stop
|
|
22
22
|
* being a console. Bearer callers still get plain text; they parse, not read.
|
|
23
23
|
*/
|
|
24
|
-
export declare function refuse(response: ServerResponse, who: Who | null, status: number, message: string, backHref?: string
|
|
24
|
+
export declare function refuse(response: ServerResponse, who: Who | null, status: number, message: string, backHref?: string,
|
|
25
|
+
/** The one action that fixes it, in place of "Go back" (easy-start C2: Reconnect). */
|
|
26
|
+
act?: {
|
|
27
|
+
label: string;
|
|
28
|
+
href: string;
|
|
29
|
+
}): void;
|
|
25
30
|
/** The demo's one-line promise, on every page. */
|
|
26
31
|
export declare const DEMO_BANNER = "Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run npx toolroll up in its folder.";
|
|
27
32
|
/** The demo notice on a phone: one line. */
|
|
@@ -233,7 +238,10 @@ export declare function personChip(name: string): Html;
|
|
|
233
238
|
/** The brand, as the workspace sidebar shows it. */
|
|
234
239
|
export declare const BRAND_HTML: Html;
|
|
235
240
|
/** A page on its own, in the workspace's look but with no script: the brand, then the page. */
|
|
236
|
-
export declare function focusDocument(title: string, body: Html
|
|
241
|
+
export declare function focusDocument(title: string, body: Html, live?: {
|
|
242
|
+
nonce: string;
|
|
243
|
+
script: string;
|
|
244
|
+
}): Html;
|
|
237
245
|
export declare function chatMoney(microusd: number | null): string;
|
|
238
246
|
export declare function relativeAge(iso: string, now: Date): string;
|
|
239
247
|
/** The project chip every all-scope row wears: null is UNPLACED, said so. */
|
package/dist/server/chrome.js
CHANGED
|
@@ -92,7 +92,9 @@ export function isOverdue(decision, now) {
|
|
|
92
92
|
* a way back — the error path is the one place a console must not stop
|
|
93
93
|
* being a console. Bearer callers still get plain text; they parse, not read.
|
|
94
94
|
*/
|
|
95
|
-
export function refuse(response, who, status, message, backHref = "/"
|
|
95
|
+
export function refuse(response, who, status, message, backHref = "/",
|
|
96
|
+
/** The one action that fixes it, in place of "Go back" (easy-start C2: Reconnect). */
|
|
97
|
+
act) {
|
|
96
98
|
if (who === null || who.via === "bearer") {
|
|
97
99
|
return respond(response, status, "text/plain; charset=utf-8", message);
|
|
98
100
|
}
|
|
@@ -103,7 +105,7 @@ export function refuse(response, who, status, message, backHref = "/") {
|
|
|
103
105
|
// The words are the caller's exactly (older callers wrote lower-case fragments; k-refusal capitalises the first letter).
|
|
104
106
|
const body = html `<div class="k-refusal">${status === 404
|
|
105
107
|
? kitNotFound(path, generic ? {} : { title: message, text: "Home has what needs you; Tasks has every task." })
|
|
106
|
-
: kitDoc(kitDocHeader({ title: "Request refused", back: [backHref, "Back"] }), kitNotice(message), kitActs(kitBtn("Go back", { href: backHref })))}</div>`;
|
|
108
|
+
: kitDoc(kitDocHeader({ title: "Request refused", back: [backHref, "Back"] }), kitNotice(message), kitActs(act === undefined ? kitBtn("Go back", { href: backHref }) : kitBtn(act.label, { href: act.href, variant: "primary" })))}</div>`;
|
|
107
109
|
// A signed-in browser keeps the workspace around it: the same navigation as every page.
|
|
108
110
|
const framed = requestContext.getStore()?.refusal;
|
|
109
111
|
if (framed !== undefined && who.via === "cookie")
|
|
@@ -111,7 +113,7 @@ export function refuse(response, who, status, message, backHref = "/") {
|
|
|
111
113
|
return page(response, status, shell(status === 404 ? "Not found" : "Request refused", outPage({
|
|
112
114
|
brand: BRAND_HTML, title: status === 404 ? "Not found" : "Request refused",
|
|
113
115
|
body: html `<div class="k-refusal">${kitNotice(status === 404 && generic ? `There\u2019s no page at ${path}.` : message)}</div>`,
|
|
114
|
-
foot: html `<a class="k-link" href="${status === 404 ? "/" : backHref}">${status === 404 ? "Go to Home" : "Go back"}</a>`,
|
|
116
|
+
foot: act === undefined ? html `<a class="k-link" href="${status === 404 ? "/" : backHref}">${status === 404 ? "Go to Home" : "Go back"}</a>` : html `<a class="k-link" href="${act.href}">${act.label}</a>`,
|
|
115
117
|
})));
|
|
116
118
|
}
|
|
117
119
|
/** The demo's one-line promise, on every page. */
|
|
@@ -652,8 +654,8 @@ export function personChip(name) {
|
|
|
652
654
|
/** The brand, as the workspace sidebar shows it. */
|
|
653
655
|
export const BRAND_HTML = html `<span class="so-brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>Toolroll`;
|
|
654
656
|
/** A page on its own, in the workspace's look but with no script: the brand, then the page. */
|
|
655
|
-
export function focusDocument(title, body) {
|
|
656
|
-
return shell(title, html `<div class="focus-page"><a class="so-wordmark focus-brand" href="/chat">${BRAND_HTML}</a>${body}</div
|
|
657
|
+
export function focusDocument(title, body, live) {
|
|
658
|
+
return shell(title, html `<div class="focus-page"><a class="so-wordmark focus-brand" href="/chat">${BRAND_HTML}</a>${body}</div>`, live === undefined ? {} : { live });
|
|
657
659
|
}
|
|
658
660
|
export function chatMoney(microusd) {
|
|
659
661
|
return microusd === null ? "unknown" : `$${(microusd / 1_000_000).toFixed(2)}`;
|
package/dist/server/console.css
CHANGED
|
@@ -2783,3 +2783,13 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
|
|
|
2783
2783
|
.put-away-item { min-height: 44px; }
|
|
2784
2784
|
.put-away-days button, .task-archived button, .work-row-status form.inline button { min-height: 44px; }
|
|
2785
2785
|
}
|
|
2786
|
+
/* The welcome (/welcome): three steps from the kit's pieces. A form holding one button sits in its row of acts. */
|
|
2787
|
+
.welcome-step .k-note { margin: 0; }
|
|
2788
|
+
.welcome-rows { display: grid; }
|
|
2789
|
+
.welcome-rows .k-sl-t { display: block; overflow-wrap: anywhere; }
|
|
2790
|
+
.welcome-rows .k-mono { overflow-wrap: anywhere; }
|
|
2791
|
+
.welcome-form, .welcome .k-srec-a form { display: contents; }
|
|
2792
|
+
.welcome .k-btn { min-height: 0; }
|
|
2793
|
+
@media (max-width: 40rem) { .welcome form > .k-btn[type=submit] { width: auto; } }
|
|
2794
|
+
/* On a phone the step's primary lives in the dock: its copy in the step is hidden. */
|
|
2795
|
+
@media (max-width: 760px) { .welcome .k-btn.k-docked { display: none; } }
|
package/dist/server/guards.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface GuardsRuntime {
|
|
|
42
42
|
signInBudget: SourceBudget;
|
|
43
43
|
passwordAllowed: (name: string) => boolean;
|
|
44
44
|
sessions: PersistentSessions;
|
|
45
|
+
/** Easy-start C2: the browser mutation key's one check (server/mutation-key.ts). */
|
|
46
|
+
browserKeys: Pick<import("./mutation-key.js").BrowserKeys, "check">;
|
|
45
47
|
}
|
|
46
48
|
export declare function createSharedGuards(runtime: GuardsRuntime): {
|
|
47
49
|
authenticateApprover: (who: Who, token: string, repo?: string | null) => ReturnType<typeof checkApproverPassword>;
|
|
@@ -49,6 +51,7 @@ export declare function createSharedGuards(runtime: GuardsRuntime): {
|
|
|
49
51
|
authorizeMutation: (request: IncomingMessage, who: Who, body: FormView<FormFieldOf<"mutationGuard">>) => {
|
|
50
52
|
status: number;
|
|
51
53
|
message: string;
|
|
54
|
+
reconnect?: import("./mutation-key.js").Reconnect;
|
|
52
55
|
} | null;
|
|
53
56
|
projectOf: (who: Who, request: IncomingMessage) => string | null | undefined;
|
|
54
57
|
resolveRouteProject: (source: RouteDeclaration['project'], url: URL, who: Who, request: IncomingMessage, body: URLSearchParams | null) => string | null;
|
package/dist/server/guards.js
CHANGED
|
@@ -9,7 +9,8 @@ import { REMOTE_MESSAGES, reproveRemote } from "../operate-remote.js";
|
|
|
9
9
|
import { canonicalProject, rowVisible } from "../project.js";
|
|
10
10
|
import { authenticateAccount, authenticateApprover as checkApproverPassword, credentialProof } from "../scope.js";
|
|
11
11
|
import { SourceBudget } from "../sign-in-guard.js";
|
|
12
|
-
import {} from "../store.js";
|
|
12
|
+
import { localModeOf } from "../store.js";
|
|
13
|
+
import { currentLocalOwner, localBindingStands, localOwnerStandsNow } from "../local-owner.js";
|
|
13
14
|
import { requestContext } from "./request-context.js";
|
|
14
15
|
import { refuse } from "./chrome.js";
|
|
15
16
|
import { matchTaskPath } from "./http.js";
|
|
@@ -105,6 +106,19 @@ export function createSharedGuards(runtime) {
|
|
|
105
106
|
if (body.get("csrf") !== who.session.csrf) {
|
|
106
107
|
return { status: 403, message: "stale form — reload and try again" };
|
|
107
108
|
}
|
|
109
|
+
// Easy-start C2: on a protected loopback install the CSRF token is rendered into pages any cookie holder can read,
|
|
110
|
+
// so a mutation also presents the session's key, which only this console's own origin holds.
|
|
111
|
+
const keyed = runtime.browserKeys.check(who, request, body.sent);
|
|
112
|
+
if (keyed !== null)
|
|
113
|
+
return keyed;
|
|
114
|
+
// Easy-start C1 (after the CSRF and key proofs, so a forged post learns nothing more): a local owner's session
|
|
115
|
+
// mutates only inside the console's admission of a row marked for it (which set the local owner this chain acts
|
|
116
|
+
// as), and only while that still stands; anywhere else it is refused.
|
|
117
|
+
if (who.authority !== "full") {
|
|
118
|
+
const local = currentLocalOwner();
|
|
119
|
+
if (local === null || local.account !== who.name || !localOwnerStandsNow(runtime.store, local))
|
|
120
|
+
return { status: 403, message: "Set a password to do this." };
|
|
121
|
+
}
|
|
108
122
|
}
|
|
109
123
|
return null;
|
|
110
124
|
}
|
|
@@ -316,7 +330,7 @@ export function createSharedGuards(runtime) {
|
|
|
316
330
|
if (match === null)
|
|
317
331
|
return null;
|
|
318
332
|
const session = lookupSession(match[1], touch);
|
|
319
|
-
return session === null ? null : { name: session.name, via: "cookie", session, role: session.role };
|
|
333
|
+
return session === null ? null : { name: session.name, via: "cookie", session, role: session.role, authority: session.authority };
|
|
320
334
|
}
|
|
321
335
|
/**
|
|
322
336
|
* Constant-time against the session table, small as it is — and every hit
|
|
@@ -350,6 +364,12 @@ export function createSharedGuards(runtime) {
|
|
|
350
364
|
runtime.sessions.delete(id);
|
|
351
365
|
return null;
|
|
352
366
|
}
|
|
367
|
+
// v147: a local owner's session stands only while local mode is on, at the generation it was made under, for its
|
|
368
|
+
// owner. Off, moved or changed hands: the session is ended, never read as full.
|
|
369
|
+
if (session.authority !== "full" && !localBindingStands(localModeOf(runtime.store), { account: session.name, localGeneration: session.localGeneration })) {
|
|
370
|
+
runtime.sessions.delete(id);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
353
373
|
// Ended in another process (or from Sessions): the cached copy is dropped, never used.
|
|
354
374
|
if (!runtime.sessions.stillSaved(id)) {
|
|
355
375
|
Map.prototype.delete.call(runtime.sessions, id);
|
package/dist/server/http.d.ts
CHANGED
|
@@ -151,6 +151,12 @@ export type ServeOptions = {
|
|
|
151
151
|
ssoFetch?: typeof fetch;
|
|
152
152
|
/** Cockpit B1: this `up` console's instance secret (null: none — the instance route answers 404). */
|
|
153
153
|
instanceSecret?: () => string | null;
|
|
154
|
+
/** v147 (easy-start C2): this console start's key epoch, minted once per start (absent: the server mints its own).
|
|
155
|
+
* A session's mutation key is bound to it; a key from another start is stale. */
|
|
156
|
+
keyEpoch?: string;
|
|
157
|
+
/** Easy-start C2: the browser mutation key. On (a real console start, operate.ts startConsole), a protected loopback
|
|
158
|
+
* install's browser sessions carry a key every mutation must present (server/mutation-key.ts). Off: unchanged. */
|
|
159
|
+
browserKey?: boolean;
|
|
154
160
|
/** v137: whether this console is draining (its `up` is stopping): /healthz says so at once, and pages say Toolroll is restarting. */
|
|
155
161
|
draining?: () => boolean;
|
|
156
162
|
/** Cockpit E: how Finder is asked to reveal a proved checkout (tests pass their own; never a launch). True: shown. */
|
|
@@ -307,3 +313,19 @@ export declare function form(request: IncomingMessage, cap?: number): Promise<UR
|
|
|
307
313
|
/** A refused chat setting (POST /chat/config): back on the lead's settings it is a ?problem=, which the page's section
|
|
308
314
|
* shows in place with the edits kept; elsewhere it stays the chat's said line. */
|
|
309
315
|
export declare function chatRefusal(back: string, words: string): string;
|
|
316
|
+
/** The longest page a refusal's "Set a password" link carries on to. */
|
|
317
|
+
export declare const PASSWORD_SET_NEXT_MAX = 512;
|
|
318
|
+
/**
|
|
319
|
+
* Where /password/set continues once the first password is set (easy-start C4): a page of this console a browser can
|
|
320
|
+
* open, as path and query only — one leading slash, never two, no backslash, no scheme or host (also once decoded),
|
|
321
|
+
* no control characters, at most PASSWORD_SET_NEXT_MAX characters, never the sign-in, sign-out, sign-up, join or
|
|
322
|
+
* password roads, never a region fetch, query keys that could carry a secret dropped. Anything else is home ("/").
|
|
323
|
+
*/
|
|
324
|
+
export declare function passwordSetNext(raw: string | null | undefined): string;
|
|
325
|
+
/**
|
|
326
|
+
* The page a refused request was on, for its "Set a password" link: a page read is itself; a refused form is its own
|
|
327
|
+
* address when a browser can open it (a settings page posting to itself), else the nearest page above it
|
|
328
|
+
* (`/t/x/complete` → `/t/x`, `/flows/example` → `/flows`), since
|
|
329
|
+
* the console sends no Referer to name the page the form was on. Checked by passwordSetNext; else home.
|
|
330
|
+
*/
|
|
331
|
+
export declare function refusalNext(method: string, url: URL): string;
|
package/dist/server/http.js
CHANGED
|
@@ -19,6 +19,7 @@ import {} from "../toolroll-update.js";
|
|
|
19
19
|
import { timingSafeEqual } from "node:crypto";
|
|
20
20
|
import {} from "node:http";
|
|
21
21
|
import { SIGN_IN_LINK_PATH } from "./session.js";
|
|
22
|
+
import { matchRoute } from "./route-table.js";
|
|
22
23
|
import {} from "../folder-look.js";
|
|
23
24
|
import {} from "../project-identity.js";
|
|
24
25
|
import {} from "../learning-look.js";
|
|
@@ -257,3 +258,60 @@ export async function form(request, cap = BODY_CAP) {
|
|
|
257
258
|
export function chatRefusal(back, words) {
|
|
258
259
|
return back === "/settings/lead" ? `/settings/lead?problem=${encodeURIComponent(words)}` : chatReturnWithSaid(back, words);
|
|
259
260
|
}
|
|
261
|
+
/** The longest page a refusal's "Set a password" link carries on to. */
|
|
262
|
+
export const PASSWORD_SET_NEXT_MAX = 512;
|
|
263
|
+
/**
|
|
264
|
+
* Where /password/set continues once the first password is set (easy-start C4): a page of this console a browser can
|
|
265
|
+
* open, as path and query only — one leading slash, never two, no backslash, no scheme or host (also once decoded),
|
|
266
|
+
* no control characters, at most PASSWORD_SET_NEXT_MAX characters, never the sign-in, sign-out, sign-up, join or
|
|
267
|
+
* password roads, never a region fetch, query keys that could carry a secret dropped. Anything else is home ("/").
|
|
268
|
+
*/
|
|
269
|
+
export function passwordSetNext(raw) {
|
|
270
|
+
if (typeof raw !== "string" || raw === "" || raw.length > PASSWORD_SET_NEXT_MAX)
|
|
271
|
+
return "/";
|
|
272
|
+
if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\") || /[\u0000-\u001f\u007f]/.test(raw))
|
|
273
|
+
return "/";
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = new URL(raw, "http://standing-orders.local");
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return "/";
|
|
280
|
+
}
|
|
281
|
+
if (parsed.origin !== "http://standing-orders.local" || parsed.username !== "" || parsed.password !== "")
|
|
282
|
+
return "/";
|
|
283
|
+
let decoded;
|
|
284
|
+
try {
|
|
285
|
+
decoded = decodeURIComponent(parsed.pathname);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return "/";
|
|
289
|
+
}
|
|
290
|
+
if (/^\/\/|\\|[\u0000-\u001f\u007f]|^\/[a-z][a-z0-9+.-]*:/i.test(decoded) || /^\/(login|logout|signup|join|password)(\/|$)/.test(decoded))
|
|
291
|
+
return "/";
|
|
292
|
+
if (parsed.searchParams.has("fragment") || matchRoute("GET", parsed.pathname, "console") === null)
|
|
293
|
+
return "/";
|
|
294
|
+
for (const key of [...parsed.searchParams.keys()])
|
|
295
|
+
if (/token|password|secret|csrf|code|key|auth/i.test(key))
|
|
296
|
+
parsed.searchParams.delete(key);
|
|
297
|
+
const query = parsed.searchParams.toString();
|
|
298
|
+
const next = `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
|
|
299
|
+
return next.length > PASSWORD_SET_NEXT_MAX ? "/" : next;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The page a refused request was on, for its "Set a password" link: a page read is itself; a refused form is its own
|
|
303
|
+
* address when a browser can open it (a settings page posting to itself), else the nearest page above it
|
|
304
|
+
* (`/t/x/complete` → `/t/x`, `/flows/example` → `/flows`), since
|
|
305
|
+
* the console sends no Referer to name the page the form was on. Checked by passwordSetNext; else home.
|
|
306
|
+
*/
|
|
307
|
+
export function refusalNext(method, url) {
|
|
308
|
+
if (method === "GET" || method === "HEAD")
|
|
309
|
+
return passwordSetNext(url.pathname + url.search);
|
|
310
|
+
const parts = url.pathname.split("/").filter(part => part !== "");
|
|
311
|
+
for (; parts.length > 0; parts.pop()) {
|
|
312
|
+
const page = passwordSetNext(`/${parts.join("/")}`);
|
|
313
|
+
if (page !== "/")
|
|
314
|
+
return page;
|
|
315
|
+
}
|
|
316
|
+
return "/";
|
|
317
|
+
}
|
package/dist/server/live.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { boardRunOf
|
|
2
|
+
import { boardRunOf } from "../agent-board.js";
|
|
3
3
|
import { agentRunFingerprint } from "../agents-board.js";
|
|
4
4
|
import { createLiveRooms, LiveConnection } from "../live-bus.js";
|
|
5
5
|
import { reproveRemote } from "../operate-remote.js";
|
|
@@ -269,20 +269,10 @@ export function createLiveHandlers(runtime, live) {
|
|
|
269
269
|
if (run === null || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
|
|
270
270
|
return json(response, 404, { error: "run" });
|
|
271
271
|
// A repair is its build's own activity: its room is the build's.
|
|
272
|
-
const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id)
|
|
273
|
-
// The watch
|
|
274
|
-
//
|
|
275
|
-
const watch = () => { try {
|
|
276
|
-
holdAgentView(store, view, who.name, runId, clock());
|
|
277
|
-
}
|
|
278
|
-
catch { /* a missed renewal only lets a ping through */ } };
|
|
272
|
+
const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id);
|
|
273
|
+
// The watch lease (agent-board.ts watchingAgent) is held by the shell's keyed POST /agents/watch on each keep-alive
|
|
274
|
+
// and released on unload (easy-start N1): this stream only reads, so a stolen cookie can't hold one to quiet pings.
|
|
279
275
|
joins.push(connection => {
|
|
280
|
-
watch();
|
|
281
|
-
connection.onKeepAlive(watch);
|
|
282
|
-
connection.onClose(() => { try {
|
|
283
|
-
releaseAgentView(store, view, clock());
|
|
284
|
-
}
|
|
285
|
-
catch { /* it lapses on its own */ } });
|
|
286
276
|
agentRooms.join(runId, { name: who.name, room: name, connection,
|
|
287
277
|
valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) });
|
|
288
278
|
});
|