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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local mode's restricted principal (docs: easy-start plan C1). A laptop install made by `up --laptop` starts with one
|
|
3
|
+
* owner who has no password: the browser and the terminal UI drive it as the **local owner**, a principal that may do
|
|
4
|
+
* the everyday acts below and nothing that creates or widens a way in. Everything else stays with **full** authority
|
|
5
|
+
* (a password typed now, an identity-provider sign-in, an API token).
|
|
6
|
+
*
|
|
7
|
+
* Types and pure helpers only: enforcement (route marks, the chat confirm gate, the prover's order) reads these.
|
|
8
|
+
*/
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
export const AUTHORITIES = Object.freeze(["full", "local-owner"]);
|
|
11
|
+
/** A runtime string as an authority; anything else is null (never "full" by default). */
|
|
12
|
+
export function authorityOf(value) {
|
|
13
|
+
return value === "full" || value === "local-owner" ? value : null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The acts a local owner may do (plan C1). Ids only: each surface (a route row, a chat action, a CLI command) names
|
|
17
|
+
* the act it is, and an act not in this list is refused to a local owner.
|
|
18
|
+
*/
|
|
19
|
+
export const LOCAL_OWNER_ACTS = Object.freeze([
|
|
20
|
+
"file-task",
|
|
21
|
+
"lead-send",
|
|
22
|
+
"approve-plan",
|
|
23
|
+
"answer-question",
|
|
24
|
+
"stop-run",
|
|
25
|
+
"pause-run",
|
|
26
|
+
"resume-run",
|
|
27
|
+
"complete-task",
|
|
28
|
+
"request-revision",
|
|
29
|
+
"archive-task",
|
|
30
|
+
"add-project",
|
|
31
|
+
"choose-default-agent",
|
|
32
|
+
"mark-seen",
|
|
33
|
+
"watch-agent",
|
|
34
|
+
"read",
|
|
35
|
+
]);
|
|
36
|
+
const ACT_SET = new Set(LOCAL_OWNER_ACTS);
|
|
37
|
+
/** Whether a runtime string names an act a local owner may do. */
|
|
38
|
+
export function localOwnerAct(value) {
|
|
39
|
+
return typeof value === "string" && ACT_SET.has(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every lead proposal kind, decided for the local owner. A new kind fails to compile here until someone decides it
|
|
43
|
+
* (like METHOD_IS_PERSON in person-proof.ts). `action` and `task_action` are allowed only for an operation that is a
|
|
44
|
+
* LOCAL_OWNER_ACTS act; the confirm gate checks that operation too.
|
|
45
|
+
*/
|
|
46
|
+
export const LOCAL_OWNER_PROPOSALS = Object.freeze({
|
|
47
|
+
task: "allow",
|
|
48
|
+
answer: "allow",
|
|
49
|
+
steer: "allow",
|
|
50
|
+
cancel: "allow",
|
|
51
|
+
hold: "allow",
|
|
52
|
+
unhold: "allow",
|
|
53
|
+
action: "allow",
|
|
54
|
+
task_action: "allow",
|
|
55
|
+
next: "refuse",
|
|
56
|
+
reserve: "refuse",
|
|
57
|
+
scope: "refuse",
|
|
58
|
+
repair: "refuse",
|
|
59
|
+
agents: "refuse",
|
|
60
|
+
review: "refuse",
|
|
61
|
+
control: "refuse",
|
|
62
|
+
});
|
|
63
|
+
/** Kinds whose allow also needs the operation itself to be a local-owner act. */
|
|
64
|
+
export const OPERATION_KINDS = new Set(["action", "task_action"]);
|
|
65
|
+
/**
|
|
66
|
+
* Whether a local owner may confirm a proposal of this kind. A runtime string: an unknown kind is refused. For
|
|
67
|
+
* `action`/`task_action`, `act` must be the local-owner act the operation is (null or unknown refuses).
|
|
68
|
+
*/
|
|
69
|
+
export function localOwnerMayConfirm(kind, act = null) {
|
|
70
|
+
if (typeof kind !== "string" || !Object.prototype.hasOwnProperty.call(LOCAL_OWNER_PROPOSALS, kind))
|
|
71
|
+
return false;
|
|
72
|
+
if (LOCAL_OWNER_PROPOSALS[kind] !== "allow")
|
|
73
|
+
return false;
|
|
74
|
+
return OPERATION_KINDS.has(kind) ? localOwnerAct(act) : true;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a local-owner session or link still stands against local mode as read now: on, at the generation it was
|
|
78
|
+
* bound to, for the same owner. Pure: the caller reads `mode` in the same transaction as the act.
|
|
79
|
+
*/
|
|
80
|
+
export function localBindingStands(mode, binding) {
|
|
81
|
+
return mode !== null && mode.enabled && binding.localGeneration !== null && mode.generation === binding.localGeneration && mode.owner === binding.account;
|
|
82
|
+
}
|
|
83
|
+
const localOwnerScope = new AsyncLocalStorage();
|
|
84
|
+
/** The local owner this async chain acts as, or null (full authority, or nobody). Work that outlived its request still
|
|
85
|
+
* reads it (so it is never mistaken for full authority); `localOwnerInFlight` says whether it may still prove anything. */
|
|
86
|
+
export function currentLocalOwner() {
|
|
87
|
+
return localOwnerScope.getStore() ?? null;
|
|
88
|
+
}
|
|
89
|
+
/** Whether a local owner's act is still in its request (or its terminal command): only then may it prove or write. */
|
|
90
|
+
export function localOwnerInFlight(principal) {
|
|
91
|
+
return principal.request === null || !principal.request.ended;
|
|
92
|
+
}
|
|
93
|
+
/** Run `body` as a local owner (the console's admission and the CLI's dispatch only). */
|
|
94
|
+
export function asLocalOwner(principal, body) {
|
|
95
|
+
return localOwnerScope.run(Object.freeze({ ...principal }), body);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Narrow the current local owner to one act for `body` (a chat card's own act inside the confirm route). Outside a
|
|
99
|
+
* local owner's chain it changes nothing. It can only ever name an allowlisted act: a caller passing null refuses.
|
|
100
|
+
*/
|
|
101
|
+
export function withLocalAct(act, body) {
|
|
102
|
+
const current = currentLocalOwner();
|
|
103
|
+
return current === null ? body() : localOwnerScope.run(Object.freeze({ ...current, act }), body);
|
|
104
|
+
}
|
|
105
|
+
const terminalRequest = new AsyncLocalStorage();
|
|
106
|
+
/**
|
|
107
|
+
* The terminal UI's door (plan C3): run CLI commands (`runOperate`) inside `fn` as the local owner. runOperate binds the
|
|
108
|
+
* owner from local mode as it reads it when its database opens, and refuses every command not marked for the local owner
|
|
109
|
+
* (surface.ts LOCAL_ALLOW). Never read from argv or the environment.
|
|
110
|
+
*/
|
|
111
|
+
export function runAsLocalOwner(fn) {
|
|
112
|
+
return terminalRequest.run(true, fn);
|
|
113
|
+
}
|
|
114
|
+
/** Whether this chain asked to run commands as the local owner (runOperate reads it; nothing else should). */
|
|
115
|
+
export function localOwnerRequested() {
|
|
116
|
+
return terminalRequest.getStore() === true;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The act each lead proposal kind is when a local owner confirms it (null: refused, as LOCAL_OWNER_PROPOSALS says).
|
|
120
|
+
* `action` and `task_action` take their operation's act instead (CHAT_ACTION_LOCAL_ACTS, CHAT_TASK_ACTION_LOCAL_ACTS).
|
|
121
|
+
*/
|
|
122
|
+
export const PROPOSAL_LOCAL_ACTS = Object.freeze({
|
|
123
|
+
task: "file-task",
|
|
124
|
+
answer: "answer-question",
|
|
125
|
+
steer: "lead-send",
|
|
126
|
+
cancel: "stop-run",
|
|
127
|
+
hold: "pause-run",
|
|
128
|
+
unhold: "resume-run",
|
|
129
|
+
action: null,
|
|
130
|
+
task_action: null,
|
|
131
|
+
next: null,
|
|
132
|
+
reserve: null,
|
|
133
|
+
scope: null,
|
|
134
|
+
repair: null,
|
|
135
|
+
agents: null,
|
|
136
|
+
review: null,
|
|
137
|
+
control: null,
|
|
138
|
+
});
|
|
139
|
+
/** Every chat action (chat-actions.ts CHAT_ACTIONS), decided for the local owner: its act, or null (refused). A new
|
|
140
|
+
* action fails to compile here until someone decides it. */
|
|
141
|
+
export const CHAT_ACTION_LOCAL_ACTS = Object.freeze({
|
|
142
|
+
skill_import: null, skill_enable: null, skill_disable: null, skill_restore: null, skill_test: null,
|
|
143
|
+
knowledge_instructions: null, knowledge_save: null, knowledge_remove: null, knowledge_restore: null,
|
|
144
|
+
tool_add: null, tool_remove: null,
|
|
145
|
+
flow_create: null, flow_starter: null, sweep_file: null, sweep_cancel_rest: null, flow_edit: null,
|
|
146
|
+
flow_card_add: null, flow_card_move: null, flow_card_approve: null, flow_card_send_back: null, flow_card_choose: null,
|
|
147
|
+
flow_card_cancel: null, flow_card_comment: null, flow_card_assign: null, flow_card_watch: null, flow_script_save: null,
|
|
148
|
+
flow_trigger_add: null, flow_trigger_pause: null, flow_trigger_resume: null, flow_trigger_remove: null,
|
|
149
|
+
flow_browser: null, flow_browser_off: null,
|
|
150
|
+
subagent_create: null, subagent_soul: null, subagent_state: null, subagent_note: null, subagent_ask: null,
|
|
151
|
+
subagent_answer: null, subagent_tools: null, subagent_memory: null, subagent_routine: null, subagent_undo: null,
|
|
152
|
+
kit_setup: null, decision_record: null, decision_retire: null, lead_about_you: null,
|
|
153
|
+
scope_approve: "approve-plan",
|
|
154
|
+
result_accept: "complete-task",
|
|
155
|
+
task_cancel: "stop-run",
|
|
156
|
+
task_resume: "resume-run",
|
|
157
|
+
});
|
|
158
|
+
/** Every task card operation (chat-task-actions.ts), decided for the local owner. */
|
|
159
|
+
export const CHAT_TASK_ACTION_LOCAL_ACTS = Object.freeze({
|
|
160
|
+
stop: "stop-run",
|
|
161
|
+
resume: "resume-run",
|
|
162
|
+
retry: null,
|
|
163
|
+
plan: null,
|
|
164
|
+
wait_for: null,
|
|
165
|
+
stop_waiting: null,
|
|
166
|
+
run_checks: null,
|
|
167
|
+
run_full_checks: null,
|
|
168
|
+
add_tests: null,
|
|
169
|
+
});
|
|
170
|
+
/**
|
|
171
|
+
* The act a saved proposal is for the local owner, read from its kind and (for operation kinds) its saved operation;
|
|
172
|
+
* null refuses. A runtime read of saved JSON: anything unknown is null.
|
|
173
|
+
*/
|
|
174
|
+
export function proposalLocalAct(kind, operation) {
|
|
175
|
+
if (typeof kind !== "string" || !Object.prototype.hasOwnProperty.call(PROPOSAL_LOCAL_ACTS, kind))
|
|
176
|
+
return null;
|
|
177
|
+
const own = (table) => typeof operation === "string" && Object.prototype.hasOwnProperty.call(table, operation) ? table[operation] ?? null : null;
|
|
178
|
+
const act = kind === "action" ? own(CHAT_ACTION_LOCAL_ACTS) : kind === "task_action" ? own(CHAT_TASK_ACTION_LOCAL_ACTS) : PROPOSAL_LOCAL_ACTS[kind];
|
|
179
|
+
return act !== null && localOwnerMayConfirm(kind, act) ? act : null;
|
|
180
|
+
}
|
|
181
|
+
/** The words every local-owner refusal says, and where it leads: set a first password, or sign in with the one set. */
|
|
182
|
+
export function localOwnerRefusal(passwordSet, returnTo) {
|
|
183
|
+
return passwordSet
|
|
184
|
+
? { message: "Sign in with your password to do this.", action: "Sign in with your password", href: `/login?return=${encodeURIComponent(returnTo)}` }
|
|
185
|
+
// /password/set continues to the refused page once the password is set (http.ts passwordSetNext re-checks it there).
|
|
186
|
+
: { message: "Set a password to do this.", action: "Set a password", href: returnTo === "/" ? "/password/set" : `/password/set?next=${encodeURIComponent(returnTo)}` };
|
|
187
|
+
}
|
|
188
|
+
/** Whether local mode is on now (absent is off). Read straight from its one row, so the store's own checks can use it. */
|
|
189
|
+
export function localModeOn(store) {
|
|
190
|
+
const row = store.handle.prepare("SELECT enabled FROM local_mode WHERE id = 1").get();
|
|
191
|
+
return row !== undefined && Number(row["enabled"]) === 1;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The write-time re-check (plan C1): a local owner acting now still stands — local mode on, at the generation it was
|
|
195
|
+
* bound to, for the same owner; the owner an active approver at the account generation it was bound to; and (a browser)
|
|
196
|
+
* its session still saved. Read in the caller's transaction: Store.transact runs it inside every write a local owner's
|
|
197
|
+
* chain makes, and the prover runs it inside the approval's own transaction.
|
|
198
|
+
*/
|
|
199
|
+
export function localOwnerStandsNow(store, principal) {
|
|
200
|
+
const mode = store.handle.prepare("SELECT owner, generation, enabled FROM local_mode WHERE id = 1").get();
|
|
201
|
+
if (mode === undefined || Number(mode["enabled"]) !== 1 || Number(mode["generation"]) !== principal.localGeneration || String(mode["owner"]) !== principal.account)
|
|
202
|
+
return false;
|
|
203
|
+
const account = store.accountOf(principal.account);
|
|
204
|
+
if (account === null || account.revokedAt !== null || account.role !== "approver" || account.generation !== principal.accountGeneration)
|
|
205
|
+
return false;
|
|
206
|
+
return principal.sessionHash === null || store.webSession(principal.sessionHash) !== null;
|
|
207
|
+
}
|
|
208
|
+
/** What a local owner's write hears when it no longer stands (local mode turned off or moved, the owner changed, the
|
|
209
|
+
* session ended). */
|
|
210
|
+
export const LOCAL_OWNER_LAPSED = "This local sign-in no longer stands. Run `toolroll open` to sign in again.";
|
|
211
|
+
/** What widening a launch's confinement hears while local mode is on (plan C5). */
|
|
212
|
+
export const LOCAL_MODE_CONFINES = "Turn off local mode first.";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The reserved folder's name, beside the database. Toolroll's own; nothing else belongs there. */
|
|
2
|
+
export declare const SECRETS_DIR = ".toolroll-secrets";
|
|
3
|
+
/** The saved login's file name, inside the folder (and, from older versions, directly beside the database). */
|
|
4
|
+
export declare const LOGIN_FILE_NAME = "up-login.txt";
|
|
5
|
+
export declare class UntrustedSecretsFolder extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** The reserved folder for a state folder (the database's folder), by name. */
|
|
8
|
+
export declare const secretsDirOf: (stateDir: string) => string;
|
|
9
|
+
/** Where the saved login lives now. */
|
|
10
|
+
export declare const loginFileOf: (stateDir: string) => string;
|
|
11
|
+
/** Where older versions saved it: directly beside the database. */
|
|
12
|
+
export declare const legacyLoginFileOf: (stateDir: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Make the reserved folder if it isn't there (owner-only), and prove it is a real folder of this user's, owner-only,
|
|
15
|
+
* reached by no link. Returns its path. Throws UntrustedSecretsFolder when something else holds the name: nothing is
|
|
16
|
+
* written there then, and the fence still denies the name.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureSecretsDir(stateDir: string): string;
|
|
19
|
+
/** The saved-login file to write: inside the reserved folder, made and proven first. Throws when it can't be. */
|
|
20
|
+
export declare function writableLoginFile(stateDir: string): string;
|
|
21
|
+
/** The saved-login file to read: the reserved folder's when anything is there, else the old place's when anything is
|
|
22
|
+
* there, else the reserved folder's (nothing saved yet). */
|
|
23
|
+
export declare function savedLoginFile(stateDir: string): string;
|
|
24
|
+
export type LoginMigration = {
|
|
25
|
+
kind: "none";
|
|
26
|
+
} | {
|
|
27
|
+
kind: "moved";
|
|
28
|
+
from: string;
|
|
29
|
+
to: string;
|
|
30
|
+
}
|
|
31
|
+
/** Both existed with the same contents: the old copy was removed. */
|
|
32
|
+
| {
|
|
33
|
+
kind: "deduplicated";
|
|
34
|
+
from: string;
|
|
35
|
+
to: string;
|
|
36
|
+
}
|
|
37
|
+
/** Both exist and differ: the reserved folder's wins for reads, the old one is left for the person. */
|
|
38
|
+
| {
|
|
39
|
+
kind: "both";
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
}
|
|
43
|
+
/** The old file is a link, not a regular file of this user's, or too large: left exactly where it is. */
|
|
44
|
+
| {
|
|
45
|
+
kind: "refused";
|
|
46
|
+
from: string;
|
|
47
|
+
reason: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "failed";
|
|
50
|
+
from: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Move a saved login from directly beside the database into the reserved folder. The new file is a fresh one (never
|
|
55
|
+
* the old file's inode, so no other name for that file — a hard link — reaches it), created owner-only (0600) inside
|
|
56
|
+
* the owner-only folder and published whole under its name (written to a private temporary name, synced, then
|
|
57
|
+
* hard-linked in, which fails rather than replace anything), and the old one is removed only after. A link or
|
|
58
|
+
* anything other than this user's own regular file at the old name is never followed or moved. Never throws.
|
|
59
|
+
*/
|
|
60
|
+
export declare function migrateLegacyLogin(stateDir: string): LoginMigration;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Toolroll keeps the saved credentials beside its database: a reserved owner-only folder, `.toolroll-secrets`,
|
|
3
|
+
* that every agent fence denies whole (agent-fence.ts). It is made before any agent launches, so a credential written
|
|
4
|
+
* into it later (the first saved login, written while a build already runs) is behind a fence that was fixed at launch.
|
|
5
|
+
* A file beside the database that appears after launch is not: bubblewrap can't deny a path that isn't there, and a
|
|
6
|
+
* fence built from a listing can't name one either.
|
|
7
|
+
*
|
|
8
|
+
* The saved login moved here from `up-login.txt` directly beside the database. Readers look here first and then at
|
|
9
|
+
* the old place; the next `up` moves an old file in (migrateLegacyLogin), never through a link, never readable by
|
|
10
|
+
* anyone else on the way.
|
|
11
|
+
*/
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { closeSync, constants as fsConstants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
/** The reserved folder's name, beside the database. Toolroll's own; nothing else belongs there. */
|
|
16
|
+
export const SECRETS_DIR = ".toolroll-secrets";
|
|
17
|
+
/** The saved login's file name, inside the folder (and, from older versions, directly beside the database). */
|
|
18
|
+
export const LOGIN_FILE_NAME = "up-login.txt";
|
|
19
|
+
/** The largest saved login read. */
|
|
20
|
+
const LOGIN_LIMIT = 8192;
|
|
21
|
+
export class UntrustedSecretsFolder extends Error {
|
|
22
|
+
}
|
|
23
|
+
/** The reserved folder for a state folder (the database's folder), by name. */
|
|
24
|
+
export const secretsDirOf = (stateDir) => join(stateDir, SECRETS_DIR);
|
|
25
|
+
/** Where the saved login lives now. */
|
|
26
|
+
export const loginFileOf = (stateDir) => join(secretsDirOf(stateDir), LOGIN_FILE_NAME);
|
|
27
|
+
/** Where older versions saved it: directly beside the database. */
|
|
28
|
+
export const legacyLoginFileOf = (stateDir) => join(stateDir, LOGIN_FILE_NAME);
|
|
29
|
+
const present = (path) => {
|
|
30
|
+
try {
|
|
31
|
+
lstatSync(path);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Make the reserved folder if it isn't there (owner-only), and prove it is a real folder of this user's, owner-only,
|
|
40
|
+
* reached by no link. Returns its path. Throws UntrustedSecretsFolder when something else holds the name: nothing is
|
|
41
|
+
* written there then, and the fence still denies the name.
|
|
42
|
+
*/
|
|
43
|
+
export function ensureSecretsDir(stateDir) {
|
|
44
|
+
let base;
|
|
45
|
+
try {
|
|
46
|
+
base = realpathSync(stateDir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new UntrustedSecretsFolder("The folder beside the database can't be resolved.");
|
|
50
|
+
}
|
|
51
|
+
const folder = join(base, SECRETS_DIR);
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(folder, { mode: 0o700 });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== "EEXIST")
|
|
57
|
+
throw new UntrustedSecretsFolder(`Could not make ${SECRETS_DIR} beside the database (${error.message}).`);
|
|
58
|
+
}
|
|
59
|
+
const stat = lstatSync(folder);
|
|
60
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0)
|
|
61
|
+
|| (process.getuid !== undefined && stat.uid !== process.getuid()) || realpathSync(folder) !== resolve(folder)) {
|
|
62
|
+
throw new UntrustedSecretsFolder(`${SECRETS_DIR} beside the database is not a private folder of yours; nothing was saved there.`);
|
|
63
|
+
}
|
|
64
|
+
return folder;
|
|
65
|
+
}
|
|
66
|
+
/** The saved-login file to write: inside the reserved folder, made and proven first. Throws when it can't be. */
|
|
67
|
+
export function writableLoginFile(stateDir) {
|
|
68
|
+
ensureSecretsDir(stateDir);
|
|
69
|
+
// By the name the caller knows the state folder by (it is shown to the person); the folder under it was just proven.
|
|
70
|
+
return loginFileOf(stateDir);
|
|
71
|
+
}
|
|
72
|
+
/** The saved-login file to read: the reserved folder's when anything is there, else the old place's when anything is
|
|
73
|
+
* there, else the reserved folder's (nothing saved yet). */
|
|
74
|
+
export function savedLoginFile(stateDir) {
|
|
75
|
+
const current = loginFileOf(stateDir);
|
|
76
|
+
if (present(current))
|
|
77
|
+
return current;
|
|
78
|
+
const legacy = legacyLoginFileOf(stateDir);
|
|
79
|
+
return present(legacy) ? legacy : current;
|
|
80
|
+
}
|
|
81
|
+
/** The bytes of a file that is a regular file of this user's, opened without following a link and without blocking on
|
|
82
|
+
* a FIFO, of at most `limit` bytes; null otherwise. */
|
|
83
|
+
function ownRegularBytes(path, limit) {
|
|
84
|
+
let fd = null;
|
|
85
|
+
try {
|
|
86
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
87
|
+
const stat = fstatSync(fd);
|
|
88
|
+
if (!stat.isFile() || stat.size > limit || (process.getuid !== undefined && stat.uid !== process.getuid()))
|
|
89
|
+
return null;
|
|
90
|
+
const bytes = readFileSync(fd);
|
|
91
|
+
return bytes.length > limit ? null : { bytes, dev: stat.dev, ino: stat.ino };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (fd !== null)
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const fsyncFolder = (folder) => {
|
|
102
|
+
let fd = null;
|
|
103
|
+
try {
|
|
104
|
+
fd = openSync(folder, fsConstants.O_RDONLY);
|
|
105
|
+
fsyncSync(fd);
|
|
106
|
+
}
|
|
107
|
+
catch { /* some filesystems refuse a folder fsync */ }
|
|
108
|
+
finally {
|
|
109
|
+
if (fd !== null)
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Move a saved login from directly beside the database into the reserved folder. The new file is a fresh one (never
|
|
115
|
+
* the old file's inode, so no other name for that file — a hard link — reaches it), created owner-only (0600) inside
|
|
116
|
+
* the owner-only folder and published whole under its name (written to a private temporary name, synced, then
|
|
117
|
+
* hard-linked in, which fails rather than replace anything), and the old one is removed only after. A link or
|
|
118
|
+
* anything other than this user's own regular file at the old name is never followed or moved. Never throws.
|
|
119
|
+
*/
|
|
120
|
+
export function migrateLegacyLogin(stateDir) {
|
|
121
|
+
const from = legacyLoginFileOf(stateDir);
|
|
122
|
+
let held;
|
|
123
|
+
try {
|
|
124
|
+
held = lstatSync(from);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { kind: "none" };
|
|
128
|
+
}
|
|
129
|
+
if (!held.isFile() || held.isSymbolicLink() || (process.getuid !== undefined && held.uid !== process.getuid())) {
|
|
130
|
+
return { kind: "refused", from, reason: `${LOGIN_FILE_NAME} beside the database is a link or not your own regular file` };
|
|
131
|
+
}
|
|
132
|
+
let folder;
|
|
133
|
+
try {
|
|
134
|
+
folder = ensureSecretsDir(stateDir);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return { kind: "failed", from, reason: error.message };
|
|
138
|
+
}
|
|
139
|
+
const to = join(folder, LOGIN_FILE_NAME);
|
|
140
|
+
const old = ownRegularBytes(from, LOGIN_LIMIT);
|
|
141
|
+
if (old === null || old.dev !== held.dev || old.ino !== held.ino) {
|
|
142
|
+
return { kind: "refused", from, reason: `${LOGIN_FILE_NAME} beside the database changed, is too large, or is not your own regular file` };
|
|
143
|
+
}
|
|
144
|
+
const removeOld = () => {
|
|
145
|
+
// Only the file that was read: a replacement since then stays for the person.
|
|
146
|
+
try {
|
|
147
|
+
const now = lstatSync(from);
|
|
148
|
+
if (!now.isFile() || now.dev !== old.dev || now.ino !== old.ino)
|
|
149
|
+
return false;
|
|
150
|
+
unlinkSync(from);
|
|
151
|
+
fsyncFolder(stateDir);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
if (present(to)) {
|
|
159
|
+
const current = ownRegularBytes(to, LOGIN_LIMIT);
|
|
160
|
+
if (current !== null && current.bytes.equals(old.bytes) && removeOld())
|
|
161
|
+
return { kind: "deduplicated", from, to };
|
|
162
|
+
return { kind: "both", from, to };
|
|
163
|
+
}
|
|
164
|
+
const temporary = join(folder, `.${LOGIN_FILE_NAME}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
165
|
+
try {
|
|
166
|
+
const fd = openSync(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
167
|
+
try {
|
|
168
|
+
writeSync(fd, old.bytes);
|
|
169
|
+
fsyncSync(fd);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
closeSync(fd);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
linkSync(temporary, to);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code === "EEXIST")
|
|
179
|
+
return { kind: "both", from, to };
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
fsyncFolder(folder);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return { kind: "failed", from, reason: error.message };
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
try {
|
|
189
|
+
unlinkSync(temporary);
|
|
190
|
+
}
|
|
191
|
+
catch { /* already gone */ }
|
|
192
|
+
}
|
|
193
|
+
removeOld();
|
|
194
|
+
return { kind: "moved", from, to };
|
|
195
|
+
}
|
package/dist/mcp-oauth.d.ts
CHANGED
|
@@ -75,6 +75,10 @@ export type OAuthHttpOptions = {
|
|
|
75
75
|
confirm: (request: IncomingMessage, session: OAuthSession, typed: string) => boolean;
|
|
76
76
|
/** The projects this person may choose: each a project the server knows that they can use now. */
|
|
77
77
|
projectsFor: (account: string) => string[];
|
|
78
|
+
/** Easy-start C2: the consent post presents the session's browser mutation key (absent: not required). */
|
|
79
|
+
keyed?: (request: IncomingMessage, form: URLSearchParams) => boolean;
|
|
80
|
+
/** Easy-start C2: whether the consent page carries the key fill (its one nonce'd script). */
|
|
81
|
+
keyFill?: () => boolean;
|
|
78
82
|
/** The token exchange's budget by source (request-budget.ts): charged once per request, before its body is read or any code or refresh secret is spent. */
|
|
79
83
|
admitToken?: (request: IncomingMessage) => Admission;
|
|
80
84
|
};
|
package/dist/mcp-oauth.js
CHANGED
|
@@ -4,7 +4,8 @@ import { hashSecret, mintApiToken } from "./api-tokens.js";
|
|
|
4
4
|
import { limitWords } from "./request-budget.js";
|
|
5
5
|
import { sourceKey } from "./source-key.js";
|
|
6
6
|
import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
|
|
7
|
-
import { html, htmlString, joinHtml, postForm, styleElement } from "./html.js";
|
|
7
|
+
import { html, htmlString, joinHtml, postForm, scriptElement, styleElement } from "./html.js";
|
|
8
|
+
import { KEY_FILL_SCRIPT } from "./server/mutation-key.js";
|
|
8
9
|
import { withFormToken } from "./server/request-context.js";
|
|
9
10
|
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
10
11
|
export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
|
|
@@ -80,11 +81,13 @@ export function createOAuthHttp(options) {
|
|
|
80
81
|
response.end(JSON.stringify(body));
|
|
81
82
|
};
|
|
82
83
|
const oauthError = (response, status, error, description) => json(response, status, { error, error_description: description });
|
|
83
|
-
/** A page of this sign-in: script-free
|
|
84
|
-
|
|
84
|
+
/** A page of this sign-in: script-free (but for the key fill, on a consent while the browser key is required), never
|
|
85
|
+
* framed, posting only to this console. */
|
|
86
|
+
const page = (response, status, document, fill = false) => {
|
|
87
|
+
const nonce = fill && options.keyFill?.() === true ? randomBytes(16).toString("base64") : null;
|
|
85
88
|
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
|
|
86
|
-
"content-security-policy":
|
|
87
|
-
response.end(htmlString(document));
|
|
89
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline';${nonce === null ? "" : ` script-src 'nonce-${nonce}';`} form-action 'self'; frame-ancestors 'none'; base-uri 'none'` });
|
|
90
|
+
response.end(nonce === null ? htmlString(document) : htmlString(document).replace(/<\/body><\/html>$/, `${htmlString(scriptElement(KEY_FILL_SCRIPT, { nonce }))}</body></html>`));
|
|
88
91
|
};
|
|
89
92
|
/**
|
|
90
93
|
* On to an address by a page that moves on by itself, never a redirect: a redirect answering a form is held to
|
|
@@ -277,7 +280,7 @@ export function createOAuthHttp(options) {
|
|
|
277
280
|
response.writeHead(303, { location: `/login?return=${encodeURIComponent(`/oauth/authorize?request=${id}`)}`, "cache-control": "no-store" });
|
|
278
281
|
return void response.end();
|
|
279
282
|
}
|
|
280
|
-
return page(response, 200, consentHtml(session, id, one, null, null));
|
|
283
|
+
return page(response, 200, consentHtml(session, id, one, null, null), true);
|
|
281
284
|
}
|
|
282
285
|
if (request.method !== "POST")
|
|
283
286
|
return problemPage(response, 405, "This sign-in isn't valid", "Start again from your MCP client.");
|
|
@@ -291,6 +294,9 @@ export function createOAuthHttp(options) {
|
|
|
291
294
|
// The person's own act: their session's CSRF value, posted from this console's own page.
|
|
292
295
|
if (text === null || !options.sameSite(request) || !same(form.get("csrf") ?? "", session.csrf))
|
|
293
296
|
return problemPage(response, 403, "This form expired", "Nothing was allowed. Start again from your MCP client.");
|
|
297
|
+
// Easy-start C2: on a protected loopback install, the key only this console's own page holds.
|
|
298
|
+
if (options.keyed !== undefined && !options.keyed(request, form))
|
|
299
|
+
return problemPage(response, 403, "Reconnect to Toolroll", "This tab isn't connected to Toolroll, so nothing was allowed. Open Toolroll, reconnect, then start again from your MCP client.");
|
|
294
300
|
const id = form.get("request") ?? "";
|
|
295
301
|
const one = waiting.get(id);
|
|
296
302
|
if (one === undefined || one.expires <= now.getTime())
|
|
@@ -312,7 +318,7 @@ export function createOAuthHttp(options) {
|
|
|
312
318
|
return problemPage(response, 403, "Sign in first", "Your access changed. Sign in again; nothing was allowed.");
|
|
313
319
|
const access = form.get("access") === "act" ? "act" : "read";
|
|
314
320
|
const chosen = [...new Set(form.getAll("project"))];
|
|
315
|
-
const again = (status, problem) => page(response, status, consentHtml(session, id, one, problem, { access, projects: chosen }));
|
|
321
|
+
const again = (status, problem) => page(response, status, consentHtml(session, id, one, problem, { access, projects: chosen }), true);
|
|
316
322
|
if (access === "act" && account.role !== "approver")
|
|
317
323
|
return again(403, "Your account can watch, so the client can only read.");
|
|
318
324
|
const mine = options.projectsFor(session.name);
|
package/dist/model-choices.d.ts
CHANGED
|
@@ -186,3 +186,46 @@ export type RecentRun = {
|
|
|
186
186
|
};
|
|
187
187
|
/** The latest runs' floating or exact choice next to the model their provider reported: no verdict. */
|
|
188
188
|
export declare function recentRuns(store: Store, limit?: number): RecentRun[];
|
|
189
|
+
/** Codex's exact ids for its "Sized by job" preset: the frontier model plans, reviews and builds large or risky
|
|
190
|
+
* work, the workhorse builds routine work, the small model builds small work (the ids Codex lists as of 2026-11). */
|
|
191
|
+
export declare const CODEX_FRONTIER = "gpt-6-astra";
|
|
192
|
+
export declare const CODEX_WORKHORSE = "gpt-6-sol";
|
|
193
|
+
export declare const CODEX_SMALL = "gpt-6-luna";
|
|
194
|
+
/** The agents a first choice can name ("Use this one", the lead chosen at a first start). */
|
|
195
|
+
export type DefaultAgent = "claude" | "codex";
|
|
196
|
+
/** Each agent's default preset: what a new installation runs on once that agent is chosen and nothing is set yet. */
|
|
197
|
+
export declare const AGENT_PRESETS: Readonly<Record<DefaultAgent, {
|
|
198
|
+
label: string;
|
|
199
|
+
words: string;
|
|
200
|
+
rows: Record<string, string | null>;
|
|
201
|
+
}>>;
|
|
202
|
+
/** An agent's default preset as a change: every slot, exact ids, repairs following each size's builder. */
|
|
203
|
+
export declare function agentPresetChange(agent: DefaultAgent): ModelChange[];
|
|
204
|
+
/** The chat provider a lead runs on, as the agent whose preset it brings; null for a direct API. */
|
|
205
|
+
export declare function defaultAgentOf(provider: string): DefaultAgent | null;
|
|
206
|
+
/**
|
|
207
|
+
* Who may fill: the local owner choosing its agent now (a browser's "Use this one", act `choose-default-agent`, still
|
|
208
|
+
* standing, re-checked here), or a laptop install's own first start choosing the agent signed in on this computer
|
|
209
|
+
* (local mode on). Nothing else: every other change of models is Settings → Models'
|
|
210
|
+
* operator step-up (applyModelChoices).
|
|
211
|
+
*/
|
|
212
|
+
export type DefaultRoutingDoor = {
|
|
213
|
+
kind: "local-owner";
|
|
214
|
+
} | {
|
|
215
|
+
kind: "first-start";
|
|
216
|
+
};
|
|
217
|
+
export type DefaultRoutingResult = {
|
|
218
|
+
filled: true;
|
|
219
|
+
rows: number;
|
|
220
|
+
} | {
|
|
221
|
+
filled: false;
|
|
222
|
+
reason: "authority" | "already-set" | "invalid";
|
|
223
|
+
problem: string;
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* A new installation's first routing: the chosen agent's default preset, written only while NOTHING is set — no role
|
|
227
|
+
* row, tier row or other-provider candidate at the installation or any project — so it never overwrites a choice.
|
|
228
|
+
* Checked and written in one transaction (the caller's, when it has one: the lead's own write rides with it), each row
|
|
229
|
+
* ledgered, the result checked like any apply; a refusal writes nothing.
|
|
230
|
+
*/
|
|
231
|
+
export declare function fillDefaultRouting(store: Store, agent: DefaultAgent, actor: string, door: DefaultRoutingDoor, now: Date): DefaultRoutingResult;
|