toolroll 0.9.42 → 0.9.44

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/chat-actions.js +1 -1
  5. package/dist/chat-approval.d.ts +45 -0
  6. package/dist/chat-approval.js +81 -0
  7. package/dist/chat-channel.js +5 -0
  8. package/dist/chat-decide.d.ts +18 -6
  9. package/dist/chat-decide.js +54 -20
  10. package/dist/chat-flow.js +2 -2
  11. package/dist/cli-http.d.ts +64 -0
  12. package/dist/cli-http.js +143 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +8 -1
  15. package/dist/contracts/cli.d.ts +13 -0
  16. package/dist/contracts/cli.js +7 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/gateway-tools.d.ts +82 -0
  20. package/dist/contracts/gateway-tools.js +33 -0
  21. package/dist/contracts/lead-tools.d.ts +11 -0
  22. package/dist/contracts/lead-tools.js +2 -0
  23. package/dist/desktop-update.d.ts +3 -1
  24. package/dist/desktop-update.js +7 -6
  25. package/dist/flow-engine.d.ts +2 -0
  26. package/dist/flow-engine.js +10 -2
  27. package/dist/flows-cli.d.ts +28 -0
  28. package/dist/flows-cli.js +43 -1
  29. package/dist/guides.js +2 -0
  30. package/dist/mate-contract.js +1 -1
  31. package/dist/mate-tools.js +43 -1
  32. package/dist/mcp-core.d.ts +110 -0
  33. package/dist/mcp-core.js +311 -0
  34. package/dist/mcp-http.d.ts +26 -0
  35. package/dist/mcp-http.js +151 -0
  36. package/dist/mcp-person.d.ts +31 -0
  37. package/dist/mcp-person.js +96 -0
  38. package/dist/mcp.d.ts +1 -8
  39. package/dist/mcp.js +21 -322
  40. package/dist/operate-remote-arguments.d.ts +36 -0
  41. package/dist/operate-remote-arguments.js +185 -0
  42. package/dist/operate-remote.d.ts +86 -0
  43. package/dist/operate-remote.js +125 -0
  44. package/dist/operate.d.ts +21 -1
  45. package/dist/operate.js +229 -15
  46. package/dist/process-recovery-eligibility.d.ts +1 -1
  47. package/dist/remote-command.d.ts +45 -0
  48. package/dist/remote-command.js +124 -0
  49. package/dist/remote-exec.d.ts +18 -0
  50. package/dist/remote-exec.js +241 -0
  51. package/dist/remote-run.d.ts +16 -0
  52. package/dist/remote-run.js +13 -0
  53. package/dist/scope.d.ts +4 -2
  54. package/dist/scope.js +10 -0
  55. package/dist/serve.d.ts +7 -0
  56. package/dist/serve.js +75 -2
  57. package/dist/store.d.ts +23 -2
  58. package/dist/store.js +76 -9
  59. package/dist/surface.d.ts +10 -3
  60. package/dist/surface.js +71 -3
  61. package/dist/team-cli.d.ts +19 -0
  62. package/dist/team-cli.js +20 -3
  63. package/dist/teammate-work.js +1 -1
  64. package/dist/telegram-flow.d.ts +41 -1
  65. package/dist/telegram-flow.js +96 -12
  66. package/dist/telegram.js +27 -3
  67. package/dist/toolroll-update.js +4 -1
  68. package/package.json +1 -1
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Remote CLI, the client half (Phase 1). With an API token saved by `toolroll connect`, a command whose contract row
3
+ * says `remote: "yes"` is sent to the central server's `POST /api/cli` and runs there as you; its stdout, stderr and
4
+ * exit code come back byte for byte. `remote: "no"` runs on this computer exactly as before, and `remote: "step-up"`
5
+ * (approvals, people and policy) is refused here: those stay in the console or chat.
6
+ *
7
+ * Nothing here opens the local database or reads a saved owner login; it never prompts, and an uncertain answer to
8
+ * a mutation is never retried.
9
+ */
10
+ import { readFileSync, statSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { setTimeout as delay } from 'node:timers/promises';
13
+ import { envelopeJson } from './envelope.js';
14
+ import { COMMAND_GUIDE } from './surface.js';
15
+ import { centralProfile, UsageError } from './team-cli.js';
16
+ import { CLI_FILES_BYTES, CLI_WAIT_SECONDS } from './cli-http.js';
17
+ import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
18
+ export { contractRow, STEP_UP_MESSAGE } from './remote-command.js';
19
+ const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
20
+ /** Team commands own their own central path (team-cli.ts). */
21
+ const TEAM_COMMANDS = new Set(['connect', 'lead', 'conversation', 'chat', 'brief']);
22
+ const RESPONSE_BYTES = 16 * 1024 * 1024;
23
+ const REQUEST_TIMEOUT_MS = (CLI_WAIT_SECONDS + 35) * 1_000;
24
+ async function bounded(response) {
25
+ if (!response.body)
26
+ throw new Error('empty');
27
+ const reader = response.body.getReader(), chunks = [];
28
+ let size = 0;
29
+ try {
30
+ while (true) {
31
+ const chunk = await reader.read();
32
+ if (chunk.done)
33
+ break;
34
+ size += chunk.value.byteLength;
35
+ if (size > RESPONSE_BYTES) {
36
+ await reader.cancel();
37
+ throw new Error('too-large');
38
+ }
39
+ chunks.push(chunk.value);
40
+ }
41
+ }
42
+ finally {
43
+ reader.releaseLock();
44
+ }
45
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
46
+ }
47
+ /** One request, never retried. */
48
+ async function post(origin, token, request, options) {
49
+ try {
50
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
51
+ const response = await (options.fetch ?? fetch)(`${origin}/api/cli`, {
52
+ method: 'POST', redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
53
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request),
54
+ });
55
+ if (response.status >= 300 && response.status < 400) {
56
+ await response.body?.cancel();
57
+ return { kind: 'unconfirmed' };
58
+ }
59
+ const payload = await bounded(response);
60
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
61
+ return { kind: 'unconfirmed' };
62
+ if (response.status === 200 && Number.isInteger(payload.exitCode) && Number(payload.exitCode) >= 0 && Number(payload.exitCode) <= 255 && typeof payload.stdout === 'string' && typeof payload.stderr === 'string') {
63
+ return { kind: 'answer', answer: { exitCode: Number(payload.exitCode), stdout: payload.stdout, stderr: payload.stderr } };
64
+ }
65
+ if (response.status >= 400 && payload.ok === false && typeof payload.code === 'string' && typeof payload.message === 'string') {
66
+ return { kind: 'refused', status: response.status, code: payload.code, message: payload.message.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '') };
67
+ }
68
+ return { kind: 'unconfirmed' };
69
+ }
70
+ catch {
71
+ return { kind: 'unconfirmed' };
72
+ }
73
+ }
74
+ /** The declared file arguments' contents, keyed by the argument exactly as typed. argv is never rewritten. */
75
+ function inlineFiles(argv, row, cwd) {
76
+ const paths = fileArguments(argv, row);
77
+ if ('problem' in paths)
78
+ throw new UsageError(paths.problem);
79
+ const files = Object.create(null);
80
+ let size = 0;
81
+ for (const value of paths) {
82
+ if (Object.hasOwn(files, value))
83
+ continue;
84
+ const path = resolve(cwd, value);
85
+ let isFile = false;
86
+ try {
87
+ isFile = statSync(path).isFile();
88
+ }
89
+ catch {
90
+ isFile = false;
91
+ }
92
+ if (!isFile)
93
+ throw new UsageError('A remote input must be a readable file.');
94
+ let content;
95
+ try {
96
+ content = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
97
+ }
98
+ catch {
99
+ throw new UsageError('A remote input must be a readable UTF-8 file.');
100
+ }
101
+ size += Buffer.byteLength(content);
102
+ if (size > CLI_FILES_BYTES)
103
+ throw new UsageError('Files sent with a remote command are limited to 256 KiB in total. Use --local to run it here.');
104
+ files[value] = content;
105
+ }
106
+ return files;
107
+ }
108
+ /**
109
+ * null: run locally with argv unchanged. `{ local }`: run locally with this argv (`--local` removed).
110
+ * A number: the command ran remotely (or was refused before sending) and this is its exit code.
111
+ */
112
+ export async function maybeRunRemoteCommand(argv, options = {}) {
113
+ const first = argv[0];
114
+ if (first === undefined || first.startsWith('-') || TEAM_COMMANDS.has(first) || first === 'help')
115
+ return null;
116
+ const out = options.stdout ?? (chunk => { process.stdout.write(chunk); });
117
+ const err = options.stderr ?? (chunk => { process.stderr.write(chunk); });
118
+ const json = argv.includes('--json');
119
+ const lookup = options.modeOf ?? contractRow;
120
+ const row = lookup(argv);
121
+ const command = row?.invocation ?? first;
122
+ const refuse = (reason, message, code) => {
123
+ if (json)
124
+ out(`${envelopeJson({ ok: false, command, reason, message })}\n`);
125
+ else
126
+ err(`${message}\n`);
127
+ return code;
128
+ };
129
+ const profileAt = argv.indexOf('--profile');
130
+ const mode = row?.mode ?? 'no';
131
+ const explicit = profileAt >= 0;
132
+ // Local commands never read the profile, and their own flags (the scan's --local) stay exactly as typed.
133
+ if (mode === 'no' && !explicit)
134
+ return null;
135
+ if (argv.includes('--local')) {
136
+ if (explicit)
137
+ return refuse('usage', '--local cannot be combined with --profile.', EXIT.usage);
138
+ // A row declaring --local keeps its own meaning; elsewhere it only means "run here".
139
+ const declared = COMMAND_GUIDE.find(one => one.invocation === row?.invocation)?.flags?.some(flag => flag.name === 'local') ?? false;
140
+ return { local: declared ? [...argv] : argv.filter(one => one !== '--local') };
141
+ }
142
+ let profile;
143
+ try {
144
+ if (profileAt >= 0 && (argv.filter(one => one === '--profile').length > 1 || !argv[profileAt + 1] || argv[profileAt + 1].startsWith('-')))
145
+ throw new UsageError('Use --profile <name> once.');
146
+ profile = centralProfile(options, profileAt >= 0 ? argv[profileAt + 1] : undefined);
147
+ }
148
+ catch (error) {
149
+ return refuse('usage', error instanceof UsageError ? error.message : 'The remote profile cannot be read.', EXIT.usage);
150
+ }
151
+ // No saved token: today's local behaviour. A profile saved with a password serves chat only.
152
+ if (profile === null || !profile.apiToken) {
153
+ if (!explicit)
154
+ return null;
155
+ return refuse('usage', 'This profile has no API token. Connect again with your so_ token (toolroll connect <origin> --token-stdin).', EXIT.usage);
156
+ }
157
+ if (row === null || mode === 'no') {
158
+ return refuse('usage', `${command} runs only on this computer. Drop --profile to run it here.`, EXIT.usage);
159
+ }
160
+ if (mode === 'step-up')
161
+ return refuse('step-up', STEP_UP_MESSAGE, EXIT.refused);
162
+ const sent = explicit ? argv.filter((_one, index) => index !== profileAt && index !== profileAt + 1) : [...argv];
163
+ let files;
164
+ try {
165
+ files = inlineFiles(sent, row, options.cwd ?? process.cwd());
166
+ }
167
+ catch (error) {
168
+ return refuse('usage', error instanceof UsageError ? error.message : 'A file could not be read.', EXIT.usage);
169
+ }
170
+ const deliver = (outcome) => {
171
+ if (outcome.kind === 'answer') {
172
+ // Byte for byte. --json answers are also captured so -o writes the same envelope a local run would.
173
+ if (json) {
174
+ try {
175
+ const parsed = JSON.parse(outcome.answer.stdout);
176
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
177
+ envelopeJson(parsed);
178
+ }
179
+ catch { /* not one envelope */ }
180
+ }
181
+ if (outcome.answer.stdout)
182
+ out(outcome.answer.stdout);
183
+ if (outcome.answer.stderr)
184
+ err(outcome.answer.stderr);
185
+ return outcome.answer.exitCode;
186
+ }
187
+ if (outcome.kind === 'refused')
188
+ return refuse(outcome.code, outcome.message, outcome.status === 401 || outcome.status === 403 || outcome.status === 501 ? EXIT.refused : outcome.status < 500 ? EXIT.usage : EXIT.failed);
189
+ return refuse('unconfirmed', row?.mutation === 'none'
190
+ ? `${profile.origin} could not be reached or answered unexpectedly.`
191
+ : `The answer from ${profile.origin} could not be confirmed. Check the task's state before running this again; it was not retried.`, EXIT.failed);
192
+ };
193
+ if (row?.invocation === 'task wait')
194
+ return waitRemotely(sent, files, profile, options, deliver);
195
+ return deliver(await post(profile.origin, profile.token, { argv: sent, files }, options));
196
+ }
197
+ /**
198
+ * `task wait` holds the server for at most CLI_WAIT_SECONDS per request; the client keeps asking within the caller's
199
+ * own --timeout (none: until the task settles or the wait is interrupted). The final answer is the server's own.
200
+ */
201
+ async function waitRemotely(argv, files, profile, options, deliver) {
202
+ const at = argv.indexOf('--timeout');
203
+ const given = at < 0 ? null : Number(argv[at + 1]);
204
+ const base = at < 0 ? argv : argv.filter((_one, index) => index !== at && index !== at + 1);
205
+ if (given !== null && (!Number.isFinite(given) || given < 0))
206
+ return deliver(await post(profile.origin, profile.token, { argv, files }, options));
207
+ const json = base.includes('--json');
208
+ const probe = json ? base : [...base, '--json'];
209
+ const started = Date.now();
210
+ const sleep = options.sleep ?? ((ms, signal) => delay(ms, undefined, { signal }));
211
+ const signal = options.signal ?? new AbortController().signal;
212
+ while (true) {
213
+ const remaining = given === null ? CLI_WAIT_SECONDS : Math.max(0, given - (Date.now() - started) / 1_000);
214
+ const slice = Math.min(CLI_WAIT_SECONDS, Math.floor(remaining * 1_000) / 1_000);
215
+ const outcome = await post(profile.origin, profile.token, { argv: [...probe, '--timeout', String(slice)], files }, options);
216
+ if (outcome.kind !== 'answer')
217
+ return deliver(outcome);
218
+ let reason;
219
+ try {
220
+ reason = JSON.parse(outcome.answer.stdout).reason;
221
+ }
222
+ catch {
223
+ return deliver(outcome);
224
+ }
225
+ const timedOut = reason === 'timeout';
226
+ if (!timedOut || slice <= 0 || given !== null && remaining - slice <= 0) {
227
+ if (json)
228
+ return deliver(outcome);
229
+ // Settled (or out of time): the server renders the final answer in the person's own format.
230
+ return deliver(await post(profile.origin, profile.token, { argv: [...base, '--timeout', '0'], files }, options));
231
+ }
232
+ if (signal.aborted)
233
+ return deliver(outcome);
234
+ try {
235
+ await sleep(250, signal);
236
+ }
237
+ catch {
238
+ return deliver(outcome);
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,16 @@
1
+ /** A principal re-proved against the store for one remote run. */
2
+ export type RemoteRun = {
3
+ readonly account: string;
4
+ readonly scope: "read" | "act";
5
+ readonly tokenName: string;
6
+ readonly source: "api" | "mcp";
7
+ /** What credential lookups hand back for this person in place of a password: unguessable, this run only. */
8
+ readonly secret: string;
9
+ /** Whether the person may use `repo` now (their token's projects and their account's current access). */
10
+ readonly allows: (repo: string | null) => boolean;
11
+ /** Files the caller sent, keyed by the argument that names them. Nothing else is read for input. */
12
+ readonly files: Readonly<Record<string, string>>;
13
+ };
14
+ /** The remote run this code is inside, or null for every local command. */
15
+ export declare function activeRemote(): RemoteRun | null;
16
+ export declare function withRemote<T>(run: RemoteRun, fn: () => T): T;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The remote run this code is inside (runOperateAs in operate.ts), if any. A leaf module so the credential
3
+ * checks every command reaches (scope.ts) can honour it without importing the command table.
4
+ */
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+ const running = new AsyncLocalStorage();
7
+ /** The remote run this code is inside, or null for every local command. */
8
+ export function activeRemote() {
9
+ return running.getStore() ?? null;
10
+ }
11
+ export function withRemote(run, fn) {
12
+ return running.run(run, fn);
13
+ }
package/dist/scope.d.ts CHANGED
@@ -263,8 +263,10 @@ export type Scope = SavedScopeTerms & {
263
263
  /** The digest that was actually agreed to, which may now be stale. */
264
264
  approvedDigest: string | null;
265
265
  /** How the approval happened (v29): 'password' = the ceremony;
266
- * 'mode' = sealed by the signer's live mode (modeDigest names it). */
267
- approvalBasis?: "password" | "mode" | null;
266
+ * 'mode' = sealed by the signer's live mode (modeDigest names it);
267
+ * 'chat' = the owner's own two taps in their paired chat, under their chat-approval setting or a signed term
268
+ * (modeDigest names that authority; the yes is the person's and doesn't lapse with it). */
269
+ approvalBasis?: "password" | "mode" | "chat" | null;
268
270
  modeDigest?: string | null;
269
271
  /** v24 (optional so hand-built scopes in tests stay valid): the working
270
272
  * execution profile, its resolution state, and the immutable snapshot
package/dist/scope.js CHANGED
@@ -4,6 +4,7 @@ import { passwordGuardOf } from "./sign-in-guard.js";
4
4
  import { validateScopeText } from "./task-text.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { projectAuthority } from "./project-access.js";
7
+ import { activeRemote } from "./remote-run.js";
7
8
  import { claimActor, currentActor, parseLeadToken } from "./actor.js";
8
9
  /**
9
10
  * What a task is allowed to become, agreed before anything builds it.
@@ -707,6 +708,15 @@ password) {
707
708
  export function authenticateAccount(store, by, secret) {
708
709
  if (store.listApprovers().length === 0)
709
710
  return { ok: false, reason: "no-approvers" };
711
+ // A remote run (runOperateAs) is its token's person: only that run's own credential names them, no password is
712
+ // checked or counted, and a read token stands as a viewer.
713
+ const remote = activeRemote();
714
+ if (remote !== null) {
715
+ const account = by === remote.account && secret === remote.secret ? store.accountOf(by) : null;
716
+ if (account === null || account.revokedAt !== null)
717
+ return { ok: false, reason: "unknown" };
718
+ return { ok: true, role: remote.scope === "act" ? account.role : "viewer", generation: account.generation };
719
+ }
710
720
  // A lead token signs in as its owner, only inside a command the lead runs with it (never a console sign-in).
711
721
  const acting = currentActor();
712
722
  if (acting?.lead === true && parseLeadToken(secret) !== null) {
package/dist/serve.d.ts CHANGED
@@ -3,6 +3,7 @@ import { CodingWorkspace } from './coding-workspace.js';
3
3
  import { type FetchLike } from "./flow-share.js";
4
4
  import { type MailSender } from "./flow-actions.js";
5
5
  import { type TriggerIo } from "./flow-triggers.js";
6
+ import { type CliHttpOptions, type RunOperateAs } from './cli-http.js';
6
7
  import { type AssignmentSnapshot } from './assignment.js';
7
8
  import type { PublishExec } from './publish.js';
8
9
  import { type TaskStatus } from './task-status.js';
@@ -24,6 +25,8 @@ import type { RevisionLineage } from "./store.js";
24
25
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
25
26
  import { type InstallMethod } from "./install-method.js";
26
27
  export type ServeOptions = {
28
+ /** Tests: runs a person's command for the HTTP MCP gateway instead of operate.ts's. */
29
+ runOperateAs?: import("./mcp-person.js").RunOperateAs;
27
30
  /** `toolroll demo`: the scripted lead that answers Chat instead of a model. */
28
31
  demoLead?: import("./demo.js").DemoLead;
29
32
  /** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
@@ -41,6 +44,10 @@ export type ServeOptions = {
41
44
  * install/push cards light up. X-Forwarded-* is never consulted.
42
45
  */
43
46
  publicUrl?: string;
47
+ /** Tests: the shared command boundary `POST /api/cli` runs (default: operate.ts's runOperateAs). */
48
+ cliRunner?: RunOperateAs;
49
+ /** Tests: remote command metadata until the shared contract declares it. */
50
+ cliModeOf?: CliHttpOptions['modeOf'];
44
51
  /** Where repos.json lives — every enrollment locks exactly this file. */
45
52
  registryPath?: string;
46
53
  /** This console fronts an `up` process: onboarding copy says how to watch. */
package/dist/serve.js CHANGED
@@ -67,6 +67,7 @@ import { learningHtml } from "./workspace-ui.js";
67
67
  import { acceptWithChecksOf, resultActsOf } from "./result-acts.js";
68
68
  import { createSessionEndpoint } from './session-server.js';
69
69
  import { handleTeamHttp } from './team-http.js';
70
+ import { handleCliHttp } from './cli-http.js';
70
71
  import { teamWorkspaceHtml } from './team-ui.js';
71
72
  import { createTeamRuntime } from './team-runtime.js';
72
73
  import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-revision.js";
@@ -154,6 +155,7 @@ import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
154
155
  import { ASSISTANTS, modelChoices, detectPreparation, previewProjectInstructions, addProjectInstructions } from "./setup-guide.js";
155
156
  import { previewSetup, approveSetup } from "./control-setup.js";
156
157
  import { controlSetupHtml, setupPreviewHtml, connectionHtml, connectionWords, hiddenFields } from "./control-ui.js";
158
+ import { ALL_PROJECTS as CHAT_APPROVAL_ALL, chatApprovalWords, setChatApproval } from "./chat-approval.js";
157
159
  import { composerSchedule, scheduleEditorHtml, scheduleEditorScript } from "./task-composer.js";
158
160
  import { listCoordinators } from "./coordinator.js";
159
161
  import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
@@ -261,6 +263,7 @@ import { budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendI
261
263
  import { targetOf } from "./monitoring.js";
262
264
  import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
263
265
  import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS } from "./api-tokens.js";
266
+ import { createMcpHttp } from "./mcp-http.js";
264
267
  import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
265
268
  import { logEvent } from "./log.js";
266
269
  import { modeTermsFromJson, modeWords, presetTerms, modeTermsJson, modeDigestOf, MODE_MAX_DAYS } from "./modes.js";
@@ -794,6 +797,21 @@ export function createDecisionServer(options) {
794
797
  return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
795
798
  }, cursor: team.cursor, streams: teamStreams,
796
799
  });
800
+ // Remote CLI: one live API token names the person; the shared command boundary decides everything else.
801
+ const cliEndpoint = (request, response) => handleCliHttp(request, response, {
802
+ authenticate: request => {
803
+ const who = identify(request, false);
804
+ const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
805
+ const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
806
+ const account = who === null ? null : store.accountOf(who.name);
807
+ if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
808
+ return null;
809
+ return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: account.projects === null ? null : [...account.projects] };
810
+ },
811
+ run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
812
+ modeOf: options.cliModeOf,
813
+ store,
814
+ });
797
815
  const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo) });
798
816
  /** The enumerable admission list for roll-up SQL: repos-only ceilings
799
817
  * enumerate themselves; root ceilings enumerate the STORED repos that
@@ -1225,6 +1243,9 @@ export function createDecisionServer(options) {
1225
1243
  if (url.searchParams.has("token")) {
1226
1244
  return respond(response, 400, "text/plain; charset=utf-8", "credentials never travel in URLs");
1227
1245
  }
1246
+ // The MCP gateway over HTTP (mcp-http.ts): coordinators and people's own API tokens, after the Host check.
1247
+ if (url.pathname === "/mcp")
1248
+ return mcpHttp(request, response);
1228
1249
  // Back from Google's consent screen (v89). The session cookie is SameSite=Strict and stays behind on
1229
1250
  // a return from another site: the visit's one-time state (made by a signed-in approver) is the proof.
1230
1251
  if (url.pathname === GOOGLE_CALLBACK && request.method === "GET")
@@ -1236,6 +1257,8 @@ export function createDecisionServer(options) {
1236
1257
  return;
1237
1258
  if (await teamEndpoint(request, response))
1238
1259
  return;
1260
+ if (await cliEndpoint(request, response))
1261
+ return;
1239
1262
  if (options.configDir !== undefined && await handleTeamsHttp(request, response, { store, dir: options.configDir, ...(options.teamsFetcher ? { fetcher: options.teamsFetcher } : {}), clock }))
1240
1263
  return;
1241
1264
  if (serveBrowserAsset(request, response, url.pathname))
@@ -1721,7 +1744,7 @@ export function createDecisionServer(options) {
1721
1744
  const ref = run === null ? null : store.refForId(run.taskRef);
1722
1745
  return { repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId, action: `${resource[1] === "d" ? "decision" : resource[1] === "i" ? "incident" : "run"} ${resource[3] ?? "view"}` };
1723
1746
  }
1724
- const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/routines/add", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke"]);
1747
+ const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/routines/add", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke", "/settings/chat-approval/save", "/settings/chat-approval/off"]);
1725
1748
  const placed = ["/tasks/add", "/routines/add"].includes(url.pathname) ? body?.get("repo")?.trim() : null;
1726
1749
  return { repo: url.pathname.startsWith("/people/") ? null : placed ? canonicalProject(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1727
1750
  action: known.has(url.pathname) ? url.pathname.slice(1).replaceAll("/", " ") : "console request" };
@@ -1749,6 +1772,9 @@ export function createDecisionServer(options) {
1749
1772
  if ((request.method === "GET" && path === "/settings/telegram") ||
1750
1773
  (request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair", "/settings/telegram/retry"].includes(path)))
1751
1774
  return true;
1775
+ // A person's own chat-approval setting; the route proves each project it names.
1776
+ if (request.method === "POST" && ["/settings/chat-approval/confirm", "/settings/chat-approval/save", "/settings/chat-approval/off"].includes(path))
1777
+ return true;
1752
1778
  // Coding routes require an instance operator, then prove saved ownership and project access.
1753
1779
  if (path === "/code" || path.startsWith("/code/"))
1754
1780
  return true;
@@ -7287,7 +7313,7 @@ export function createDecisionServer(options) {
7287
7313
  const note = (body.get("note") ?? "").trim() || null;
7288
7314
  if (note !== null && note.length > LIMITS.note)
7289
7315
  return answer(400, { ok: false, said: `Keep the note to ${LIMITS.note.toLocaleString("en-US")} characters; this is ${note.length.toLocaleString("en-US")}.` });
7290
- const decided = decideFlowCard(store, { card: target.id, decision, note, actor: who.name, repos: projects, evidenceRoot, draft: body.get("draft") }, now);
7316
+ const decided = decideFlowCard(store, { card: target.id, decision, note, actor: who.name, repos: projects, evidenceRoot, draft: body.get("draft"), where: "the console" }, now);
7291
7317
  return decided.ok ? settle(decided.said) : answer(409, { ok: false, said: decided.message });
7292
7318
  }
7293
7319
  if (verb === "choose") {
@@ -8287,6 +8313,51 @@ export function createDecisionServer(options) {
8287
8313
  store.retryTelegramReplies(who.name, now);
8288
8314
  return redirect(response, "/settings/telegram");
8289
8315
  }
8316
+ // Approving from chat, as the person's lasting setting (chat-approval.ts): for all their projects or one. Turning it
8317
+ // on or changing its limits is the mode's ceremony (the exact terms, a single-use nonce bound to them, the password
8318
+ // typed again); turning it off is one step, csrf only. Every change is a ledger line.
8319
+ if (url.pathname === "/settings/chat-approval/confirm" || url.pathname === "/settings/chat-approval/save" || url.pathname === "/settings/chat-approval/off") {
8320
+ const body = readForm(posted, CONSOLE_FORMS.chatApproval);
8321
+ if (who.via !== "cookie" || who.role !== "approver")
8322
+ return refuse(response, who, 403, "An approver turns on approving from their own chat.", "/settings");
8323
+ if (["scope", "full-access", "cap-usd", "nonce", "digest", "token"].some(key => body.getAll(key).length > 1))
8324
+ return refuse(response, who, 400, "Submit one value for each field.", "/settings");
8325
+ const given = (body.get("scope") ?? "all").trim();
8326
+ const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : canonicalProject(given) ?? given;
8327
+ if (scope !== CHAT_APPROVAL_ALL && !(visible(scope) && store.accountCanAccess(who.name, scope)))
8328
+ return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
8329
+ const where = scope === CHAT_APPROVAL_ALL ? "all your projects" : projectName(scope);
8330
+ if (url.pathname.endsWith("/off")) {
8331
+ const saved = setChatApproval(store, { approver: who.name, scope, enabled: false, via: "the console" }, now);
8332
+ return saved.ok ? redirect(response, `/settings?said=${encodeURIComponent(saved.said)}`) : refuse(response, who, 400, saved.message, "/settings");
8333
+ }
8334
+ const usd = (body.get("cap-usd") ?? "").trim();
8335
+ const cap = usd === "" ? null : Number(usd);
8336
+ if (cap !== null && !(Number.isFinite(cap) && cap >= 0 && cap <= 10_000))
8337
+ return refuse(response, who, 400, "Give the attempt limit in dollars, like 5.", "/settings");
8338
+ const limits = { fullAccess: body.get("full-access") === "1", capMicrousd: cap === null ? null : Math.round(cap * 1_000_000) };
8339
+ const digest = createHash("sha256").update(JSON.stringify({ scope, ...limits })).digest("hex");
8340
+ if (url.pathname.endsWith("/confirm")) {
8341
+ const nonce = mintApprovalNonce(who.name, "chat-approval", digest);
8342
+ const html = `<h1 style="overflow-wrap:anywhere">Approve from chat in ${escape(where)}</h1>` +
8343
+ `<form method="post" action="/settings/chat-approval/save" class="card approve-form">` +
8344
+ hiddenFields({ csrf: who.session.csrf, nonce, digest, scope: scope === CHAT_APPROVAL_ALL ? "all" : scope, "full-access": limits.fullAccess ? "1" : "", "cap-usd": usd }) +
8345
+ `<p>Your own taps in your paired chat approve plans and merges until you turn this off. It doesn't expire.</p>` +
8346
+ `<p class="recap">Limits: ${escape(chatApprovalWords(limits))}.</p>` +
8347
+ `<p class="recap">Plans written for you, protected paths, two-person rules and your organisation's policy still open in Toolroll.</p>` +
8348
+ `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>` +
8349
+ `<div class="sticky-actions"><button type="submit">Turn on</button></div></form>` +
8350
+ `<p class="meta"><a href="/settings">Back</a></p>`;
8351
+ return sendScreen(response, 200, screen("Approve from chat", html, { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
8352
+ }
8353
+ if (!consumeApprovalNonce(body.get("nonce") ?? "", who.name, "chat-approval", body.get("digest") ?? "") || body.get("digest") !== digest)
8354
+ return refuse(response, who, 409, "That form is stale. Read the terms again.", "/settings");
8355
+ const proved = authenticateAccount(store, who.name, body.get("token") ?? "");
8356
+ if (!proved.ok || proved.role !== "approver" || proved.generation !== who.session.generation)
8357
+ return refuse(response, who, 403, "Turning on approving from chat takes your password, typed again.", "/settings");
8358
+ const saved = setChatApproval(store, { approver: who.name, scope, enabled: true, limits, via: "the console" }, now);
8359
+ return saved.ok ? redirect(response, `/settings?said=${encodeURIComponent(saved.said)}`) : refuse(response, who, 400, saved.message, "/settings");
8360
+ }
8290
8361
  if (url.pathname === "/settings/telegram/pair" || url.pathname === "/settings/telegram/unpair") {
8291
8362
  const body = readForm(posted, CONSOLE_FORMS.telegramPair);
8292
8363
  // The person's own pairing, under their password: a code minted for
@@ -11709,6 +11780,8 @@ export function createDecisionServer(options) {
11709
11780
  approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
11710
11781
  }));
11711
11782
  }
11783
+ const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request) !== null,
11784
+ enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
11712
11785
  // ---- identity ------------------------------------------------------------
11713
11786
  function identify(request, touch = true) {
11714
11787
  // v101: an API token. Wrong ones spend the address's tries like a wrong password; expired or revoked ones, and a removed account's, name no one.
package/dist/store.d.ts CHANGED
@@ -65,6 +65,13 @@ export declare const MATE_ASK_SCHEMA = "\nCREATE TABLE IF NOT EXISTS mate_ask (\
65
65
  * per option for one visit of one card, placed on the message it rides. A reply to that message is the note
66
66
  * (telegram_flow_prompt, mode send-back). The chat apps keep their buttons beside their messages (chat-delivery-state.ts). */
67
67
  export declare const FLOW_SEND_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_send (\n card INTEGER NOT NULL REFERENCES flow_card(id),\n entry INTEGER NOT NULL,\n stage TEXT NOT NULL,\n person TEXT NOT NULL,\n content_json TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (card, entry)\n);\nCREATE TABLE IF NOT EXISTS telegram_flow_choice (\n token TEXT PRIMARY KEY,\n binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,\n card INTEGER NOT NULL REFERENCES flow_card(id),\n entry INTEGER NOT NULL,\n choice INTEGER NOT NULL,\n label TEXT NOT NULL,\n chat_id TEXT NOT NULL,\n message_id TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);\n";
68
+ /** An owner's lasting choice to approve plans and merges from their paired chat (chat-approval.ts), for all their
69
+ * projects (scope '*') or one (its path), with the limits they agreed to. Off unless a row says on; a project's own row
70
+ * outranks the all-projects one. `generation` is the account's at the change: a reset password ends it. */
71
+ export declare const CHAT_APPROVAL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_approval_setting (\n approver TEXT NOT NULL,\n scope TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n full_access INTEGER NOT NULL DEFAULT 0 CHECK (full_access IN (0,1)),\n attempt_cap_microusd INTEGER CHECK (attempt_cap_microusd IS NULL OR attempt_cap_microusd >= 0),\n generation INTEGER NOT NULL,\n digest TEXT NOT NULL,\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (approver, scope)\n);\n";
72
+ /** The second tap on a flow decision in Telegram (telegram-flow.ts): Approve arms Yes and Cancel, bound to the binding,
73
+ * chat, message and exact visit of the card; Yes decides it once. */
74
+ export declare const TELEGRAM_FLOW_CONFIRM_SCHEMA = "\nCREATE TABLE IF NOT EXISTS telegram_flow_confirm (\n token TEXT PRIMARY KEY,\n binding INTEGER NOT NULL,\n chat_id TEXT NOT NULL,\n message_id TEXT NOT NULL,\n card INTEGER NOT NULL,\n entry INTEGER NOT NULL,\n phase TEXT NOT NULL CHECK (phase IN ('yes','cancel')),\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS telegram_flow_confirm_visit ON telegram_flow_confirm (card, entry);\n";
68
75
  export declare const CHAT_DECIDE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_decide_action (\n token TEXT PRIMARY KEY,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n message TEXT,\n act TEXT NOT NULL CHECK (act IN ('accept','changes','retry','approve','not-now','merge')),\n phase TEXT NOT NULL CHECK (phase IN ('offer','yes','cancel')),\n task_id TEXT NOT NULL,\n run INTEGER,\n digest TEXT NOT NULL,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_action_message ON chat_decide_action (channel, chat, message);\nCREATE TABLE IF NOT EXISTS chat_decide_prompt (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel TEXT NOT NULL,\n binding INTEGER NOT NULL,\n chat TEXT NOT NULL,\n task_id TEXT NOT NULL,\n run INTEGER NOT NULL,\n digest TEXT NOT NULL,\n message TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n consumed_at TEXT\n);\nCREATE INDEX IF NOT EXISTS chat_decide_prompt_open ON chat_decide_prompt (channel, binding, consumed_at);\n";
69
76
  export type MateAsk = {
70
77
  turn: number;
@@ -138,7 +145,21 @@ export type SealedLedgerEntry = LedgerEntry & {
138
145
  hash: string;
139
146
  } | null;
140
147
  };
141
- export declare const SCHEMA_VERSION = 109;
148
+ export declare const SCHEMA_VERSION = 110;
149
+ /**
150
+ * The migrations `toolroll update` may carry a database through in place: each
151
+ * one only adds (a table, a column, an accepted value) and leaves every saved row
152
+ * as it was, so the updater's rehearsal on a copy (same history, integrity ok)
153
+ * proves it and the verified backup undoes it. Any migration not listed here
154
+ * needs the separate verified migration procedure. The desktop update never
155
+ * migrates and does not read this list.
156
+ *
157
+ * v110 rebuilds action_ledger only to widen its source check ('api', 'mcp'):
158
+ * every row, id and hash-chain link is copied unchanged.
159
+ */
160
+ export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
161
+ /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
162
+ export declare function updateSafeSchema(version: number | null): boolean;
142
163
  export type ApiTokenRow = {
143
164
  id: string;
144
165
  account: string;
@@ -2274,7 +2295,7 @@ export declare class Store {
2274
2295
  * exactly what the signed digest was bound to. Returns false when the
2275
2296
  * scope vanished mid-ceremony. */
2276
2297
  sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
2277
- kind: "mode";
2298
+ kind: "mode" | "chat";
2278
2299
  modeDigest: string;
2279
2300
  }, approverKind?: ApproverKind): boolean;
2280
2301
  /** Whether this task's scope carries a LIVE seal: approved, and the