toolroll 0.9.88 → 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 +65 -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 +30 -30
- 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-channel.js +11 -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/egress.js +5 -0
- package/dist/first-run.d.ts +77 -0
- package/dist/first-run.js +45 -0
- package/dist/flow-starters.js +3 -0
- package/dist/flow-triggers.js +4 -0
- package/dist/folder-gate.d.ts +32 -0
- package/dist/folder-gate.js +43 -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-access.js +15 -1
- package/dist/project-admission.d.ts +6 -1
- package/dist/project-admission.js +44 -7
- package/dist/project-identity.d.ts +8 -0
- package/dist/project-identity.js +43 -0
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/project.d.ts +1 -0
- package/dist/project.js +6 -0
- package/dist/proposal.js +11 -1
- package/dist/quick-start.d.ts +3 -1
- package/dist/quick-start.js +6 -1
- package/dist/recipes.js +12 -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 +157 -28
- 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 +12 -9
- 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 +16 -6
- 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/setup-guide.js +6 -0
- 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/teardown-git.js +16 -1
- 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/dist/youiyoux-intake.js +11 -1
- package/package.json +4 -2
package/dist/scope.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS, type EvidenceKind } from "./contracts/acceptance-terms.js";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { type LocalTransport } from "./local-owner.js";
|
|
3
4
|
import { isAcpProfile, type AcpProviderId } from "./acp-agents.js";
|
|
4
5
|
export { isAcpProfile };
|
|
5
6
|
import { type NetworkProfile } from "./egress.js";
|
|
@@ -651,7 +652,9 @@ export type FreshOp = "approve-standard" | "chat-send" | "reply-question";
|
|
|
651
652
|
/** "session": Passwordless is on and this is the person's own live browser session (docs/plans/passwordless.md). */
|
|
652
653
|
/** "remote-token" / "lead-token": a machine credential stood in for the person (a remote run's API token, a lead's
|
|
653
654
|
* token) — fine for ordinary acts it is allowed, never for a credential act (it would mint a way in with no password). */
|
|
654
|
-
|
|
655
|
+
/** "local-owner" (easy-start C1): the local owner of a laptop install, in an allowlisted act (local-owner.ts) — a person,
|
|
656
|
+
* never a credential act, and its approvals stand only while local mode stays on at their generation. */
|
|
657
|
+
export type AuthMethod = "password-now" | "sso-now" | "fresh-password" | "fresh-sso" | "session" | "cli-login" | "remote-token" | "lead-token" | "local-owner";
|
|
655
658
|
/**
|
|
656
659
|
* The local CLI's remembered login, marked for the one command that read it (operate.ts credentialsFrom): with
|
|
657
660
|
* Passwordless on, a proof made with it records "cli-login" — never a typed password — and no credential act accepts it.
|
|
@@ -794,7 +797,10 @@ export declare function approve(store: Store, taskId: string, by: string, now: D
|
|
|
794
797
|
/** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
|
|
795
798
|
export declare const APPROVAL_PROOF = "approval proved";
|
|
796
799
|
/** The proof row's detail: when the person was proved, the generation, and the exact digest they said yes to. */
|
|
797
|
-
export declare const approvalProofDetail: (at: number, generation: number, digest: string
|
|
800
|
+
export declare const approvalProofDetail: (at: number, generation: number, digest: string, local?: {
|
|
801
|
+
generation: number;
|
|
802
|
+
transport: LocalTransport;
|
|
803
|
+
}) => string;
|
|
798
804
|
/** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
|
|
799
805
|
export declare function approvalProofOf(store: Store, taskId: string, by: string, digest: string): {
|
|
800
806
|
method: string;
|
|
@@ -1007,3 +1013,19 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
|
|
|
1007
1013
|
/** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
|
|
1008
1014
|
* only: freshnessFor decides whether it stands in, at the moment of use. */
|
|
1009
1015
|
export declare function freshUntil(at: string): string | null;
|
|
1016
|
+
export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed";
|
|
1017
|
+
/**
|
|
1018
|
+
* Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
|
|
1019
|
+
* preview (the task page's approve form and the chat review render no password field when it is ok), the chat
|
|
1020
|
+
* precheck (an empty field passes) and the seal itself (approve(), inside its transaction). Refused: local mode off or
|
|
1021
|
+
* another person; no plan; release or deploy terms; protected or two-person work; an escalated profile; work a
|
|
1022
|
+
* coordinator filed; a project whose checks are stored as unsandboxed (or a plan that sealed that). Plans a lead or
|
|
1023
|
+
* a scout proposed are allowed.
|
|
1024
|
+
*/
|
|
1025
|
+
export declare function localApprovalEligible(store: Store, by: string, taskId: string): {
|
|
1026
|
+
ok: true;
|
|
1027
|
+
} | {
|
|
1028
|
+
ok: false;
|
|
1029
|
+
reason: LocalApprovalRefusal;
|
|
1030
|
+
message: string;
|
|
1031
|
+
};
|
package/dist/scope.js
CHANGED
|
@@ -6,6 +6,7 @@ import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from ".
|
|
|
6
6
|
import { validateScopeText } from "./task-text.js";
|
|
7
7
|
import { contractChangesOf } from "./contract-changes.js";
|
|
8
8
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
|
+
import { currentLocalOwner, localModeOn, localOwnerInFlight, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
|
|
9
10
|
import { projectAuthority } from "./project-access.js";
|
|
10
11
|
import { activeRemote } from "./remote-run.js";
|
|
11
12
|
import { claimActor, currentActor } from "./actor.js";
|
|
@@ -824,7 +825,9 @@ export function authenticateAccount(store, by, secret) {
|
|
|
824
825
|
const hash = account?.credentialHash ?? dummy;
|
|
825
826
|
if (!hash.startsWith("scrypt$"))
|
|
826
827
|
verifyCredential(dummy, secret);
|
|
827
|
-
|
|
828
|
+
// v147: an account with no password set (a local-mode owner) has a random hash; the KDF still runs (same timing), and
|
|
829
|
+
// nothing it is given ever matches.
|
|
830
|
+
const matches = verifyCredential(hash, secret) && account !== null && account.passwordSet;
|
|
828
831
|
if (account === null || !matches) {
|
|
829
832
|
guard.failed(by, now, source, account !== null);
|
|
830
833
|
return { ok: false, reason: "unknown" };
|
|
@@ -902,6 +905,16 @@ export function ssoNowAvailable(by, now = Date.now()) {
|
|
|
902
905
|
*/
|
|
903
906
|
export function proveAccountNow(store, by, token, options = {}) {
|
|
904
907
|
const now = Date.now();
|
|
908
|
+
// Easy-start C1: a local owner is decided FIRST, before Passwordless and freshness, whatever was typed. An allowlisted
|
|
909
|
+
// act of its own, still standing (re-read here, inside the caller's transaction), is "local-owner"; anything else —
|
|
910
|
+
// a credential act, a read, another person, local mode moved — proves nothing. It never upgrades to a typed password.
|
|
911
|
+
// A lead's token or a remote API token in that chain is never the local owner (it stays a machine's proof below).
|
|
912
|
+
const local = activeRemote() === null && currentActor()?.lead !== true ? currentLocalOwner() : null;
|
|
913
|
+
if (local !== null) {
|
|
914
|
+
if (options.credential === true || local.account !== by || local.act === "read" || !localOwnerInFlight(local) || !localOwnerStandsNow(store, local))
|
|
915
|
+
return { ok: false, reason: "not-an-approver" };
|
|
916
|
+
return { ok: true, method: "local-owner", at: now };
|
|
917
|
+
}
|
|
905
918
|
const facts = freshFacts.getStore();
|
|
906
919
|
if (facts != null && facts.actor === by && !facts.live())
|
|
907
920
|
return { ok: false, reason: "not-an-approver" };
|
|
@@ -1072,6 +1085,16 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1072
1085
|
// credential, not a person at a ceremony. Refused before any vote or proof row is written.
|
|
1073
1086
|
if (!personProof(authenticated.method))
|
|
1074
1087
|
return { ok: false, reason: "machine-proof" };
|
|
1088
|
+
// Easy-start C5: a local owner approves only as the approve-plan act, and only eligible work (one shared rule:
|
|
1089
|
+
// localApprovalEligible, read again here inside the seal's transaction).
|
|
1090
|
+
const local = authenticated.method === "local-owner" ? currentLocalOwner() : null;
|
|
1091
|
+
if (authenticated.method === "local-owner") {
|
|
1092
|
+
if (local === null || local.act !== "approve-plan")
|
|
1093
|
+
return { ok: false, reason: "policy", message: localOwnerRefusal(store.accountOf(by)?.passwordSet === true, "/").message };
|
|
1094
|
+
const eligible = localApprovalEligible(store, by, taskId);
|
|
1095
|
+
if (!eligible.ok)
|
|
1096
|
+
return { ok: false, reason: "policy", message: eligible.message };
|
|
1097
|
+
}
|
|
1075
1098
|
const scope = store.getScope(taskId);
|
|
1076
1099
|
if (scope === null)
|
|
1077
1100
|
return { ok: false, reason: "no-scope" };
|
|
@@ -1121,7 +1144,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1121
1144
|
// How the person was proved, beside every yes they give (a vote kept for protected work, or the seal): a password
|
|
1122
1145
|
// typed now, a re-check now, or a remembered one — with when, the credential generation and the exact digest.
|
|
1123
1146
|
const proved = () => store.recordAction({ at: now.toISOString(), actor: by, repo: store.lookupRef(taskId)?.repo ?? null, taskId, runId: null, action: APPROVAL_PROOF,
|
|
1124
|
-
outcome: authenticated.method, source: "work", detail: approvalProofDetail(authenticated.at, store.accountOf(by)?.generation ?? 0, scope.digest) });
|
|
1147
|
+
outcome: authenticated.method, source: "work", detail: approvalProofDetail(authenticated.at, store.accountOf(by)?.generation ?? 0, scope.digest, local === null ? undefined : { generation: local.localGeneration, transport: local.transport }) });
|
|
1125
1148
|
if (gate.verdict === "vote") {
|
|
1126
1149
|
// The same person again adds nothing: no new vote, no proof row (and the approval roads refund a re-check).
|
|
1127
1150
|
if (gate.already)
|
|
@@ -1161,7 +1184,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1161
1184
|
/** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
|
|
1162
1185
|
export const APPROVAL_PROOF = "approval proved";
|
|
1163
1186
|
/** The proof row's detail: when the person was proved, the generation, and the exact digest they said yes to. */
|
|
1164
|
-
export const approvalProofDetail = (at, generation, digest) => `${new Date(at).toISOString()} · generation ${generation} · digest ${digest}`;
|
|
1187
|
+
export const approvalProofDetail = (at, generation, digest, local) => `${new Date(at).toISOString()} · generation ${generation}${local === undefined ? "" : ` · local ${local.generation} ${local.transport}`} · digest ${digest}`;
|
|
1165
1188
|
/** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
|
|
1166
1189
|
export function approvalProofOf(store, taskId, by, digest) {
|
|
1167
1190
|
const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
|
|
@@ -1398,6 +1421,10 @@ export function proofDetailOf(detail) {
|
|
|
1398
1421
|
}
|
|
1399
1422
|
}
|
|
1400
1423
|
const parts = detail.split(" · ");
|
|
1424
|
+
// Easy-start C5: a local owner's proof names its local generation and transport between the two (approvalProofDetail).
|
|
1425
|
+
const local = parts.length === 4 ? parts.splice(2, 1)[0] : null;
|
|
1426
|
+
if (local !== null && !/^local \d+ (browser|terminal)$/.test(local))
|
|
1427
|
+
return null;
|
|
1401
1428
|
if (parts.length !== 3 || !parts[2].startsWith("digest ") || !/^generation \d+$/.test(parts[1]) || Number.isNaN(Date.parse(parts[0])))
|
|
1402
1429
|
return null;
|
|
1403
1430
|
return { at: parts[0], digest: parts[2].slice("digest ".length) };
|
|
@@ -1616,3 +1643,47 @@ export function freshUntil(at) {
|
|
|
1616
1643
|
const ms = Date.parse(at);
|
|
1617
1644
|
return Number.isFinite(ms) ? new Date(ms + FRESH_WINDOW_MS).toISOString() : null;
|
|
1618
1645
|
}
|
|
1646
|
+
const LOCAL_APPROVAL_WORDS = {
|
|
1647
|
+
"local-mode-off": "Local mode is off: approve with your password.",
|
|
1648
|
+
"not-owner": "Only this computer's owner approves here without a password.",
|
|
1649
|
+
"no-scope": "This task has no plan to approve yet.",
|
|
1650
|
+
release: "Releases and deploys need your password.",
|
|
1651
|
+
protected: "Protected work needs your password and a second approver.",
|
|
1652
|
+
escalated: "Work with wider permissions needs your password.",
|
|
1653
|
+
coordinator: "Work a coordinator filed needs your password.",
|
|
1654
|
+
unsandboxed: "This project's checks run without a sandbox, so approving needs your password.",
|
|
1655
|
+
};
|
|
1656
|
+
/**
|
|
1657
|
+
* Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
|
|
1658
|
+
* preview (the task page's approve form and the chat review render no password field when it is ok), the chat
|
|
1659
|
+
* precheck (an empty field passes) and the seal itself (approve(), inside its transaction). Refused: local mode off or
|
|
1660
|
+
* another person; no plan; release or deploy terms; protected or two-person work; an escalated profile; work a
|
|
1661
|
+
* coordinator filed; a project whose checks are stored as unsandboxed (or a plan that sealed that). Plans a lead or
|
|
1662
|
+
* a scout proposed are allowed.
|
|
1663
|
+
*/
|
|
1664
|
+
export function localApprovalEligible(store, by, taskId) {
|
|
1665
|
+
const no = (reason) => ({ ok: false, reason, message: LOCAL_APPROVAL_WORDS[reason] });
|
|
1666
|
+
if (!localModeOn(store))
|
|
1667
|
+
return no("local-mode-off");
|
|
1668
|
+
const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
|
|
1669
|
+
if (owner !== by)
|
|
1670
|
+
return no("not-owner");
|
|
1671
|
+
const scope = store.getScope(taskId);
|
|
1672
|
+
if (scope === null)
|
|
1673
|
+
return no("no-scope");
|
|
1674
|
+
if (deployGateScope(scope) || releaseCheckScope(scope))
|
|
1675
|
+
return no("release");
|
|
1676
|
+
const gate = store.approvalGate(taskId, by, "person");
|
|
1677
|
+
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1678
|
+
return no("protected");
|
|
1679
|
+
if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
|
|
1680
|
+
return no("escalated");
|
|
1681
|
+
const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
|
|
1682
|
+
if (ref === undefined || ref["coordinator_cid"] != null)
|
|
1683
|
+
return no("coordinator");
|
|
1684
|
+
// The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
|
|
1685
|
+
const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
|
|
1686
|
+
if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
|
|
1687
|
+
return no("unsandboxed");
|
|
1688
|
+
return { ok: true };
|
|
1689
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { secretFolder } from "./tool-secret-store.js";
|
|
2
2
|
import { acceptedIdentities, recoverLearning } from "./project-learning.js";
|
|
3
|
-
import { syncProjectIdentities } from "./project-identity.js";
|
|
3
|
+
import { consoleFolderGate, syncProjectIdentities } from "./project-identity.js";
|
|
4
|
+
import { inFolderScope } from "./folder-gate.js";
|
|
4
5
|
import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
|
|
5
6
|
import { loadOrCreateVapidKeys } from "./push.js";
|
|
6
7
|
import { checkoutPlan } from "./checkout-cleanup.js";
|
|
@@ -153,19 +154,22 @@ import { createPagesHandlers } from './server/pages.js';
|
|
|
153
154
|
import { createPeopleHandlers } from './server/people-tokens.js';
|
|
154
155
|
import { createSettingsHandlers } from './server/settings.js';
|
|
155
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";
|
|
156
159
|
import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
|
|
157
|
-
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";
|
|
158
161
|
import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, NOT_STARTED_SESSION, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
|
|
159
162
|
import {} from "./server/render-pages.js";
|
|
160
|
-
import { serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
|
|
163
|
+
import { localOwnerStepUps, serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
|
|
161
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";
|
|
162
165
|
import { createTasksHandlers } from './server/tasks.js';
|
|
163
166
|
import { KIT_BEHAVIOR_MARKUP, KIT_STANDALONE_SCRIPT } from './server/kit-standalone.js';
|
|
164
167
|
import { createWelcomeHandlers } from './server/welcome.js';
|
|
168
|
+
import { createBrowserKeys, KEY_FILL_SCRIPT, pagePathOf } from './server/mutation-key.js';
|
|
165
169
|
import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
|
|
166
170
|
import { sourceKey } from "./source-key.js";
|
|
167
171
|
import { readSsoSettings } from "./sso-settings.js";
|
|
168
|
-
import { LEAD_THREAD } from "./store.js";
|
|
172
|
+
import { LEAD_THREAD, localOwnerBinding } from "./store.js";
|
|
169
173
|
import { waitingUpdate } from "./toolroll-update.js";
|
|
170
174
|
import { PACKAGE_VERSION } from "./version.js";
|
|
171
175
|
import { loadConsoleUrl } from "./webhooks.js";
|
|
@@ -210,6 +214,7 @@ export function createDecisionServer(options) {
|
|
|
210
214
|
get signInBudget() { return signInBudget; },
|
|
211
215
|
get passwordAllowed() { return passwordAllowed; },
|
|
212
216
|
get sessions() { return sessions; },
|
|
217
|
+
get browserKeys() { return browserKeys; },
|
|
213
218
|
});
|
|
214
219
|
const { store, evidenceRoot } = options;
|
|
215
220
|
let coding = options.codingWorkspace ?? null;
|
|
@@ -362,17 +367,13 @@ export function createDecisionServer(options) {
|
|
|
362
367
|
const linkKey = (code) => createHash("sha256").update(code).digest("hex");
|
|
363
368
|
function mintSignInLink(account) {
|
|
364
369
|
const known = store.accountOf(account);
|
|
365
|
-
|
|
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)
|
|
366
372
|
return null;
|
|
367
|
-
|
|
368
|
-
if (one.expires < Date.now())
|
|
369
|
-
signInLinks.delete(key);
|
|
370
|
-
while (signInLinks.size >= 20)
|
|
371
|
-
signInLinks.delete(signInLinks.keys().next().value);
|
|
372
|
-
const code = randomBytes(32).toString("base64url");
|
|
373
|
-
signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
374
|
-
return `${SIGN_IN_LINK_PATH}${code}`;
|
|
373
|
+
return holdSignInLink({ kind: "full", account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
375
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");
|
|
376
377
|
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
|
|
377
378
|
* any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. */
|
|
378
379
|
const fromThisComputer = (request) => {
|
|
@@ -670,7 +671,7 @@ export function createDecisionServer(options) {
|
|
|
670
671
|
instrumentRequest(store.telemetry, request, response);
|
|
671
672
|
// Every password checked while answering counts against where this request came from (sign-in-guard.ts).
|
|
672
673
|
// A GET or HEAD only reads (docs/plans/get-side-effects.md): store reads that would tidy up as they go don't.
|
|
673
|
-
const answer = () => handle(request, response);
|
|
674
|
+
const answer = () => consoleFolders(options, identities, () => handle(request, response));
|
|
674
675
|
void withPasswordSource(joinSourceOf(request), () => request.method === "GET" || request.method === "HEAD" ? asReadOnlyRequest(answer) : answer()).catch(error => {
|
|
675
676
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query. The reference
|
|
676
677
|
// is what a person's error page shows, so the log line can be found from it.
|
|
@@ -753,6 +754,20 @@ export function createDecisionServer(options) {
|
|
|
753
754
|
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
|
|
754
755
|
return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
|
|
755
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
|
+
});
|
|
756
771
|
/** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
|
|
757
772
|
const consoleCommand = (change) => {
|
|
758
773
|
const port = servedPort() ?? 4180;
|
|
@@ -823,6 +838,17 @@ export function createDecisionServer(options) {
|
|
|
823
838
|
if (!allowedHost(request.headers.host)) {
|
|
824
839
|
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
825
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
|
+
}
|
|
826
852
|
// A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
|
|
827
853
|
// plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
|
|
828
854
|
const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
|
|
@@ -914,6 +940,13 @@ export function createDecisionServer(options) {
|
|
|
914
940
|
...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
|
|
915
941
|
workspaceRequest: url.searchParams.get('request'),
|
|
916
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)) : "/"),
|
|
917
950
|
};
|
|
918
951
|
response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
|
|
919
952
|
if (method === "GET" || method === "POST")
|
|
@@ -931,6 +964,12 @@ export function createDecisionServer(options) {
|
|
|
931
964
|
return restricted()
|
|
932
965
|
? refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects")
|
|
933
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
|
+
}
|
|
934
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));
|
|
935
974
|
if (!policy.ok) {
|
|
936
975
|
if (policy.reason === "project")
|
|
@@ -983,14 +1022,20 @@ export function createDecisionServer(options) {
|
|
|
983
1022
|
}
|
|
984
1023
|
return handlePost(route, url, who, request, response, body);
|
|
985
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);
|
|
986
1031
|
if (method === "GET")
|
|
987
|
-
return projectAuthority.run({ actor: who.name, repo: target.repo },
|
|
1032
|
+
return projectAuthority.run({ actor: who.name, repo: target.repo }, run);
|
|
988
1033
|
// Request acceptance is separate from durable work completion. Never
|
|
989
1034
|
// persist a body, query string, token, password, or arbitrary URL.
|
|
990
1035
|
const entry = { at: clock().toISOString(), actor: who.name, ...target, source: "request", ...(who.via === "bearer" && who.token !== undefined ? { detail: `API token: ${who.token}` } : {}) };
|
|
991
1036
|
store.recordAction({ ...entry, outcome: "requested" });
|
|
992
1037
|
try {
|
|
993
|
-
await projectAuthority.run({ actor: who.name, repo: target.repo },
|
|
1038
|
+
await projectAuthority.run({ actor: who.name, repo: target.repo }, run);
|
|
994
1039
|
const createdTask = requestContext.getStore()?.createdTask;
|
|
995
1040
|
const placement = createdTask === undefined ? null : store.lookupRef(createdTask);
|
|
996
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" });
|
|
@@ -1194,8 +1239,15 @@ export function createDecisionServer(options) {
|
|
|
1194
1239
|
if (provider === null)
|
|
1195
1240
|
return;
|
|
1196
1241
|
const now = clock();
|
|
1197
|
-
store.
|
|
1198
|
-
|
|
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
|
+
});
|
|
1199
1251
|
}
|
|
1200
1252
|
// Look once at start, so the lead is on by the time the browser opens.
|
|
1201
1253
|
if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
|
|
@@ -1516,9 +1568,16 @@ export function createDecisionServer(options) {
|
|
|
1516
1568
|
if (s.workspace?.view?.kind === "flow")
|
|
1517
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)}` } } } };
|
|
1518
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
|
+
}
|
|
1519
1576
|
// D5: team chat (the central team service) is deprecated; /chat?team=1 still opens this release, but nothing links to it.
|
|
1520
1577
|
const sensitive = authored || s.forceSensitive === true;
|
|
1521
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 : "";
|
|
1522
1581
|
// The settings frame (settings-kit.ts): its breadcrumb, for the shell and the workspace alike.
|
|
1523
1582
|
if (s.chrome !== undefined && s.chrome.crumbs === undefined) {
|
|
1524
1583
|
const crumbs = settingsCrumbs(new URL(requestFacts?.returnTo ?? '/', 'http://standing-orders.local').pathname, s.title, htmlString(s.body));
|
|
@@ -1667,6 +1726,8 @@ export function createDecisionServer(options) {
|
|
|
1667
1726
|
...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
|
|
1668
1727
|
...(extras.phone === undefined ? {} : { phone: extras.phone }),
|
|
1669
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 }),
|
|
1670
1731
|
};
|
|
1671
1732
|
if (requestFacts.workspaceRead) {
|
|
1672
1733
|
const validator = requestFacts.workspaceValidator;
|
|
@@ -1692,13 +1753,18 @@ export function createDecisionServer(options) {
|
|
|
1692
1753
|
// A page with its own view keeps its script; the chat page runs the result script.
|
|
1693
1754
|
const functional = conversation === null || extras.view ? (s.functional?.script ?? '') : RESULT_REVIEW_SCRIPT;
|
|
1694
1755
|
const document = shell(s.title, s.body, { chrome: s.chrome, sensitive });
|
|
1695
|
-
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);
|
|
1696
1757
|
}
|
|
1697
1758
|
// A one-time secret on screen (a token, an invite link, a pairing code): the
|
|
1698
1759
|
// page carries no script of any kind, so it leaves the workspace for a
|
|
1699
1760
|
// focused page in the same look: the brand, the one card, and the way back.
|
|
1700
|
-
|
|
1701
|
-
|
|
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
|
+
}
|
|
1702
1768
|
const chromeLayer = !sensitive && s.chrome !== undefined;
|
|
1703
1769
|
const functional = s.functional?.script ?? "";
|
|
1704
1770
|
// Sensitive pages strip the palette and keys but keep the sidebar toggle.
|
|
@@ -1706,7 +1772,7 @@ export function createDecisionServer(options) {
|
|
|
1706
1772
|
// Kit markup the workspace bundle would have wired (a composer, an overlay, a switch, an idea) gets the kit's own small
|
|
1707
1773
|
// behaviour here (kit-standalone.ts); never on a sensitive page, which carries no chrome additions.
|
|
1708
1774
|
const kitBehavior = chromeLayer && KIT_BEHAVIOR_MARKUP.test(htmlString(s.body)) ? KIT_STANDALONE_SCRIPT : "";
|
|
1709
|
-
const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome);
|
|
1775
|
+
const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome) + keyFill;
|
|
1710
1776
|
const nonce = script === "" ? undefined : randomBytes(16).toString("base64");
|
|
1711
1777
|
const body = chromeLayer
|
|
1712
1778
|
? html `${s.body}\n${paletteTagCached(s.chrome?.project ?? null)}\n${KBD_HELP}`
|
|
@@ -1971,7 +2037,7 @@ export function createDecisionServer(options) {
|
|
|
1971
2037
|
// and role were admitted by the route table.
|
|
1972
2038
|
const denied = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
|
|
1973
2039
|
if (denied !== null)
|
|
1974
|
-
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);
|
|
1975
2041
|
// Standing can change after a token was minted: a viewer's act stays refused, cookie and bearer alike, except
|
|
1976
2042
|
// the row's session-local acts.
|
|
1977
2043
|
if (who.role === "viewer" && !route.viewer)
|
|
@@ -1986,7 +2052,9 @@ export function createDecisionServer(options) {
|
|
|
1986
2052
|
// deterministic scan is idle without new meaningful work; shutdown drains it.
|
|
1987
2053
|
let leadMaintenance = null;
|
|
1988
2054
|
let leadClosing = false;
|
|
1989
|
-
|
|
2055
|
+
// Started in the console's folder scope (folder-gate.ts), so their own timers and the personal queue's kick (which
|
|
2056
|
+
// restores the context it was started in, never a request's) read folders as the console's work does.
|
|
2057
|
+
server.once('listening', () => consoleFolders(options, identities, () => {
|
|
1990
2058
|
team.start();
|
|
1991
2059
|
personalQueue.start();
|
|
1992
2060
|
leadMaintenance = startMaintenance({ intervalMs: 5_000, shouldStop: () => leadClosing,
|
|
@@ -1996,7 +2064,7 @@ export function createDecisionServer(options) {
|
|
|
1996
2064
|
// Pending delivery is durable and is read again on the next pass; a failure is said where follow status is read.
|
|
1997
2065
|
onError: error => recordLeadFollowFailure(store, error, clock()),
|
|
1998
2066
|
});
|
|
1999
|
-
});
|
|
2067
|
+
}));
|
|
2000
2068
|
// Background checks (docs/plans/get-side-effects.md): what pages show about this computer's agent sign-ins and the
|
|
2001
2069
|
// services Toolroll uses is asked here, on a timer, and a page reads the last answer. A page visit never starts a
|
|
2002
2070
|
// process or a network call, and never acts on what a check finds: lifting a sign-in pause and the lead-by-default
|
|
@@ -2011,7 +2079,7 @@ export function createDecisionServer(options) {
|
|
|
2011
2079
|
}
|
|
2012
2080
|
catch { /* a page says secrets can't be read; the next action reports why */ }
|
|
2013
2081
|
backgroundChecks = startMaintenance({ intervalMs: options.backgroundChecksMs ?? BACKGROUND_CHECKS_MS, shouldStop: () => leadClosing,
|
|
2014
|
-
run: async () => {
|
|
2082
|
+
run: () => consoleFolders(options, identities, async () => {
|
|
2015
2083
|
const now = Date.now();
|
|
2016
2084
|
const waits = [];
|
|
2017
2085
|
if (now - agentsCheckedAt >= (options.backgroundChecksMs ?? LOCAL_AGENTS_EVERY_MS)) {
|
|
@@ -2109,7 +2177,7 @@ export function createDecisionServer(options) {
|
|
|
2109
2177
|
catch { /* shown as last saved */ }
|
|
2110
2178
|
}
|
|
2111
2179
|
await Promise.all(waits);
|
|
2112
|
-
},
|
|
2180
|
+
}),
|
|
2113
2181
|
onError: () => { }, });
|
|
2114
2182
|
void backgroundChecks.runNow();
|
|
2115
2183
|
});
|
|
@@ -2303,6 +2371,8 @@ export function createDecisionServer(options) {
|
|
|
2303
2371
|
get firstTasks() { return firstTasks; },
|
|
2304
2372
|
get authenticateCredential() { return authenticateCredential; },
|
|
2305
2373
|
get identities() { return identities; },
|
|
2374
|
+
get keyEpoch() { return keyEpoch; },
|
|
2375
|
+
get browserKeys() { return browserKeys; },
|
|
2306
2376
|
};
|
|
2307
2377
|
const tasksHandlers = createTasksHandlers(handlerRuntime);
|
|
2308
2378
|
const pagesHandlers = createPagesHandlers(handlerRuntime);
|
|
@@ -2312,6 +2382,7 @@ export function createDecisionServer(options) {
|
|
|
2312
2382
|
const peopleHandlers = createPeopleHandlers(handlerRuntime);
|
|
2313
2383
|
const remoteRuntime = {
|
|
2314
2384
|
get identify() { return identify; },
|
|
2385
|
+
get browserKeys() { return browserKeys; },
|
|
2315
2386
|
get freshFactsOf() { return freshFactsOf; },
|
|
2316
2387
|
get store() { return store; },
|
|
2317
2388
|
get admitPasswordSource() { return admitPasswordSource; },
|
|
@@ -2370,7 +2441,46 @@ export function createDecisionServer(options) {
|
|
|
2370
2441
|
}
|
|
2371
2442
|
return withEdgePolicy(route, () => registry.edge(route, { ...ctx, route }));
|
|
2372
2443
|
}
|
|
2373
|
-
|
|
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 });
|
|
2374
2484
|
}
|
|
2375
2485
|
export { PAGE_CSS, pinnedTheme, SENSITIVE_INPUT } from "./server/chrome.js";
|
|
2376
2486
|
export { redactedPath, SHUTDOWN_WAIT_MS } from "./server/http.js";
|
|
@@ -2415,3 +2525,22 @@ function startupCeiling(repos, roots, identities, canon) {
|
|
|
2415
2525
|
configuredPaths,
|
|
2416
2526
|
};
|
|
2417
2527
|
}
|
|
2528
|
+
/** Each console's folder gate (folder-gate.ts), made once for its options. */
|
|
2529
|
+
const consoleGates = new WeakMap();
|
|
2530
|
+
/**
|
|
2531
|
+
* Runs the console's own work (a request, a background pass) in its folder scope: with identities resolved off its
|
|
2532
|
+
* thread (up and serve), it reads a person's folder synchronously only while that folder answered a look lately; its
|
|
2533
|
+
* own data (the database's folder, evidence, build checkouts) is read freely. Without identities (embedders, tests):
|
|
2534
|
+
* as before. Work that isn't the console's (a worker in the same process) never runs in it.
|
|
2535
|
+
*/
|
|
2536
|
+
function consoleFolders(options, identities, work) {
|
|
2537
|
+
if (options.identities === undefined)
|
|
2538
|
+
return work();
|
|
2539
|
+
let gate = consoleGates.get(options);
|
|
2540
|
+
if (gate === undefined) {
|
|
2541
|
+
const databaseFile = options.store.databaseFile();
|
|
2542
|
+
gate = consoleFolderGate(identities, [options.configDir, options.evidenceRoot, options.poolRoot, databaseFile === null ? undefined : dirname(databaseFile)]);
|
|
2543
|
+
consoleGates.set(options, gate);
|
|
2544
|
+
}
|
|
2545
|
+
return inFolderScope(gate, work);
|
|
2546
|
+
}
|
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. */
|