toolroll 0.9.43 → 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 (44) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/cli-http.d.ts +64 -0
  5. package/dist/cli-http.js +143 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.js +7 -0
  8. package/dist/contracts/cli.d.ts +13 -0
  9. package/dist/contracts/cli.js +7 -0
  10. package/dist/contracts/gateway-tools.d.ts +82 -0
  11. package/dist/contracts/gateway-tools.js +33 -0
  12. package/dist/desktop-update.d.ts +3 -1
  13. package/dist/desktop-update.js +7 -6
  14. package/dist/mcp-core.d.ts +110 -0
  15. package/dist/mcp-core.js +311 -0
  16. package/dist/mcp-http.d.ts +26 -0
  17. package/dist/mcp-http.js +151 -0
  18. package/dist/mcp-person.d.ts +31 -0
  19. package/dist/mcp-person.js +96 -0
  20. package/dist/mcp.d.ts +1 -8
  21. package/dist/mcp.js +21 -322
  22. package/dist/operate-remote-arguments.d.ts +36 -0
  23. package/dist/operate-remote-arguments.js +185 -0
  24. package/dist/operate-remote.d.ts +86 -0
  25. package/dist/operate-remote.js +125 -0
  26. package/dist/operate.d.ts +21 -1
  27. package/dist/operate.js +176 -12
  28. package/dist/remote-command.d.ts +45 -0
  29. package/dist/remote-command.js +124 -0
  30. package/dist/remote-exec.d.ts +18 -0
  31. package/dist/remote-exec.js +241 -0
  32. package/dist/remote-run.d.ts +16 -0
  33. package/dist/remote-run.js +13 -0
  34. package/dist/scope.js +10 -0
  35. package/dist/serve.d.ts +7 -0
  36. package/dist/serve.js +24 -0
  37. package/dist/store.d.ts +15 -1
  38. package/dist/store.js +34 -7
  39. package/dist/surface.d.ts +10 -3
  40. package/dist/surface.js +67 -2
  41. package/dist/team-cli.d.ts +19 -0
  42. package/dist/team-cli.js +20 -3
  43. package/dist/toolroll-update.js +4 -1
  44. package/package.json +1 -1
@@ -0,0 +1,185 @@
1
+ const args = (positionals = '', values = '', references = {}) => ({
2
+ positionals: positionals.split(' ').filter(Boolean).map(word => {
3
+ const [name = '', type = 'value'] = word.split(':');
4
+ return { name: name.replace(/[?*]$/, ''), kind: type, optional: name.endsWith('?'), rest: name.endsWith('*') };
5
+ }),
6
+ flags: new Map([...['json', 'help', 'repo', ...values.split(' ').filter(Boolean)].map(name => [name, 'value']), ...Object.entries(references)]),
7
+ });
8
+ /** All remotely allowed rows, including those with no resource references. Credentials and file-output
9
+ * flags are refused by the outer boundary. `source` on memory is provenance text; on revise it is a scope
10
+ * digest. Neither dereferences a conversation, task or proposal. Creation IDs are never looked up. */
11
+ export const REMOTE_ARGUMENTS = new Map([
12
+ ...['status', 'ready', 'grants', 'gaps', 'sync', 'runner list', 'coordinator list', 'incident list',
13
+ 'routine list', 'intake show', 'intake run', 'template list', 'webhook status', 'webhook test', 'review show',
14
+ 'chat-approval show', 'chat-approval off'].map(row => [row, args()]),
15
+ ['integrations', args('', 'saved')],
16
+ ['outbox list', args('', 'all')],
17
+ ['task add', args('title*', 'key report checks', { id: 'refused', replaces: 'task' })],
18
+ ['task ask', args('id:task', 'person why')],
19
+ ['task checks', args('task:task', 'level')],
20
+ ['task add-tests', args('task:task')],
21
+ ['task list', args('', 'state view limit cursor')],
22
+ ['task show', args('id:task')],
23
+ ['task wait', args('id:task', 'timeout')],
24
+ ['task complete', args('task:task', 'digest', { run: 'run', 'pull-request': 'refused' })],
25
+ ['task revise', args('task:task', 'feedback source key', { run: 'run' })],
26
+ ['task state', args('id:task state', 'key reason', { 'replaced-by': 'task' })],
27
+ ['task block', args('id:task', 'key', { on: 'task' })],
28
+ ['task unblock', args('id:task', 'key', { on: 'task' })],
29
+ ['task next', args('id:task', 'key undo')],
30
+ ['task steer', args('id:task', 'note')],
31
+ ['task assign', args('id:task', 'key runner anyone')],
32
+ ['task scope', args('id:task', 'key goal not touches budget-usd risk acceptance candidate compare model provider race race-count race-per-usd race-total-usd repair-model')],
33
+ ['task plan', args('id:task', 'key provider model')],
34
+ ['task hold', args('id:task', 'key reason until')],
35
+ ['task unhold', args('id:task', 'key')],
36
+ ['task require', args('id:task', 'key cap require')],
37
+ ['task requeue', args('id:task')],
38
+ ['task review', args('id:review', 'brief all', { run: 'run' })],
39
+ ['task repair', args('run:run', '', { yes: 'step-up' })],
40
+ ['task route', args('id:task', 'risk phase provider model clear-phase digest size risky')],
41
+ ['task reopen', args('id:task')],
42
+ ['task stop', args('id:task', '', { run: 'run' })],
43
+ ['task resume', args('id:task', '', { run: 'run' })],
44
+ ['check-progress', args('run:run')],
45
+ ['cap list', args()], ['cap add', args('name', 'kind probe expires')],
46
+ // These objects are installation-scoped at the outer boundary; only an all-project principal reaches them.
47
+ ['incident resolve', args('incident')],
48
+ ['routine show', args('id')],
49
+ ['routine add', args('name', 'goal schedule acceptance ceiling budget-usd not touches require')],
50
+ ...['refresh', 'pause', 'resume', 'run-now'].map(action => [`routine ${action}`, args('name')]),
51
+ ['config show', args('phase?')], ['verify show', args()],
52
+ ['intake preview', args()], ['intake pr-comments', args('', 'limit')],
53
+ ['template show', args('name')],
54
+ ['contest show', args('id')], ['contest exclude', args('contest ordinal')],
55
+ ...['search', 'impact'].map(action => [`knowledge ${action}`, args('query', 'base')]),
56
+ ['knowledge refresh', args('', 'base')],
57
+ // Decision reads and supersession are already project-bound in project-memory.ts. Proposal IDs are
58
+ // accepted only by memory apply (step-up), never by the listing commands below.
59
+ ...['search', 'decisions', 'show', 'decide', 'retire', 'review', 'status'].map(action => [`memory ${action}`,
60
+ args(['search', 'decide'].includes(action) ? 'query*' : ['show', 'retire'].includes(action) ? 'query' : '', 'why reason supersedes source all decision sessions no-local')]),
61
+ ['flows list', args()],
62
+ ['flows show', args('flow:flow')], ['flows export', args('flow:flow')],
63
+ ['flows create', args('', 'yes name template', { steps: 'steps' })],
64
+ ['flows edit', args('flow:flow', 'yes name', { steps: 'steps' })],
65
+ ['flows trigger add', args('flow:flow trigger:trigger-json', 'yes')],
66
+ ...['pause', 'resume', 'remove'].map(action => [`flows trigger ${action}`, args('flow:flow trigger:trigger')]),
67
+ ['flows card add', args('flow:flow', 'title description zone')],
68
+ ['flows archive', args('flow:flow', 'yes')],
69
+ ['models status', args('', 'provider')], ['models list', args('', 'provider')],
70
+ ]);
71
+ export const REMOTE_NOT_FOUND = { ok: false, reason: 'not-found', message: 'Not found.' };
72
+ const positiveId = (value) => /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(Number(value));
73
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
74
+ /** Read-only authorization of every supplied reference, before the handler can disclose or mutate it. */
75
+ export function remoteArgumentsOf(store, allows, invocation, positional, flags, named, files) {
76
+ const audit = REMOTE_ARGUMENTS.get(invocation);
77
+ if (audit === undefined)
78
+ return REMOTE_NOT_FOUND;
79
+ const values = positional.slice(invocation.split(' ').length - 1);
80
+ const resolved = [];
81
+ const admit = (repo) => typeof repo === 'string' && allows(repo) && (named === null || named === repo);
82
+ const input = (value) => {
83
+ const raw = value.trim().startsWith('{') || value.trim().startsWith('[') ? value
84
+ : Object.prototype.hasOwnProperty.call(files, value) ? files[value] : undefined;
85
+ if (raw === undefined)
86
+ return undefined;
87
+ try {
88
+ return JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ };
94
+ const check = (kind, value) => {
95
+ if (kind === 'value')
96
+ return true;
97
+ if (kind === 'review')
98
+ return check(flags.has('brief') ? 'task' : 'run', value);
99
+ if (kind === 'task' || kind === 'run') {
100
+ if (kind === 'task' && (!value || value.length > 64 || /[\x00-\x1f\x7f]/.test(value)))
101
+ return false;
102
+ if (kind === 'run' && !positiveId(value))
103
+ return false;
104
+ const run = kind === 'run' ? store.getRun(Number(value)) : null;
105
+ const ref = kind === 'run' ? run === null ? null : store.refById(run.taskRef) : store.lookupRef(value);
106
+ if (ref === null || !admit(ref.repo) || store.lookupRef(ref.externalId)?.id !== ref.id || store.getTask(ref.externalId) === null)
107
+ return false;
108
+ resolved.push({ repo: ref.repo, taskId: ref.externalId, taskRef: ref.id, kind });
109
+ return true;
110
+ }
111
+ if (kind === 'flow' || kind === 'trigger') {
112
+ if (!positiveId(value) || value.length > 10)
113
+ return false;
114
+ const trigger = kind === 'trigger' ? store.getFlowTrigger(Number(value)) : null;
115
+ const flow = kind === 'flow' ? store.getFlow(Number(value)) : trigger === null ? null : store.getFlow(trigger.flow);
116
+ if (flow === null || flow.state !== 'active' || !admit(flow.repo) || trigger?.state === 'removed')
117
+ return false;
118
+ resolved.push({ repo: flow.repo, taskId: null, ...(kind === 'flow' ? { flow: flow.id } : { parentFlow: trigger.flow }), kind });
119
+ return true;
120
+ }
121
+ if (kind === 'trigger-json') {
122
+ const config = input(value);
123
+ // Invalid/unavailable input cannot reach a resource lookup; the handler reports its existing file/JSON error.
124
+ if (!object(config))
125
+ return true;
126
+ return config['kind'] !== 'flow' || (typeof config['flow'] === 'number' && check('flow', String(config['flow'])));
127
+ }
128
+ if (kind === 'steps') {
129
+ const config = input(value), steps = object(config) ? config['steps'] : config;
130
+ if (!Array.isArray(steps))
131
+ return true;
132
+ // Build steps can name another project. Check the token ceiling as well as the owner's account grants.
133
+ return steps.every(step => !object(step) || step['repo'] === undefined ||
134
+ typeof step['repo'] === 'string' && store.knownRepos().includes(step['repo']) && allows(step['repo']));
135
+ }
136
+ return false;
137
+ };
138
+ for (const [name, value] of flags) {
139
+ const kind = audit.flags.get(name);
140
+ if (kind === 'step-up')
141
+ return { ok: false, reason: 'step-up', message: 'approve in the console or chat' };
142
+ if (kind === 'refused')
143
+ return { ok: false, reason: 'not-remote', message: 'That option is not available remotely.' };
144
+ if (kind === undefined)
145
+ return { ok: false, reason: 'usage', message: 'That option is not available for this remote command.' };
146
+ // Resolve positionals first below so the primary ledger target is stable regardless of flag order.
147
+ if (kind !== 'value' && typeof value !== 'string')
148
+ return REMOTE_NOT_FOUND;
149
+ }
150
+ for (let i = 0; i < audit.positionals.length; i++) {
151
+ const arg = audit.positionals[i];
152
+ const value = values[i];
153
+ if (value === undefined) {
154
+ if (arg.optional)
155
+ continue;
156
+ return REMOTE_NOT_FOUND;
157
+ }
158
+ if (!check(arg.kind, value))
159
+ return REMOTE_NOT_FOUND;
160
+ }
161
+ if (values.length > audit.positionals.length && !audit.positionals.at(-1)?.rest)
162
+ return REMOTE_NOT_FOUND;
163
+ for (const [name, value] of flags)
164
+ if (!check(audit.flags.get(name), String(value)))
165
+ return REMOTE_NOT_FOUND;
166
+ const task = resolved.find(ref => ref.kind === 'task');
167
+ for (const ref of resolved) {
168
+ if (ref.kind === 'run' && task !== undefined && ref.taskRef !== task.taskRef) {
169
+ // Review/revise accept a saved result from the same revision family. Stop/resume name an exact attempt.
170
+ const runFamily = store.taskFamilyOf(ref.taskId, [ref.repo], false);
171
+ const taskFamily = store.taskFamilyOf(task.taskId, [task.repo], false);
172
+ if (!['task review', 'task revise'].includes(invocation) || ref.repo !== task.repo || runFamily === null || taskFamily === null ||
173
+ runFamily.root.id !== taskFamily.root.id)
174
+ return REMOTE_NOT_FOUND;
175
+ }
176
+ if (ref.parentFlow !== undefined && ref.parentFlow !== resolved.find(one => one.kind === 'flow')?.flow)
177
+ return REMOTE_NOT_FOUND;
178
+ }
179
+ if (invocation === 'task repair') {
180
+ const draft = store.repairChainFor(Number(values[0]))?.draftTask;
181
+ if (draft != null && (!check('task', draft) || resolved.at(-1)?.repo !== resolved[0]?.repo))
182
+ return REMOTE_NOT_FOUND;
183
+ }
184
+ return { ok: true, repo: resolved[0]?.repo ?? named, taskId: resolved[0]?.taskId ?? null };
185
+ }
@@ -0,0 +1,86 @@
1
+ import type { Store } from "./store.js";
2
+ import type { LedgerSource } from "./action-ledger.js";
3
+ import { type CommandRow } from "./surface.js";
4
+ /** Who a remote call is, as the transport proved it from an `so_` token. */
5
+ export type Principal = {
6
+ kind: "person";
7
+ account: string;
8
+ /** The account's credential generation when the token was checked. */
9
+ generation: number;
10
+ scope: "read" | "act";
11
+ tokenId: string;
12
+ /** The projects the person may use (null: all of them). */
13
+ projects: string[] | null;
14
+ };
15
+ export type RemoteSource = Extract<LedgerSource, "api" | "mcp">;
16
+ export { activeRemote, withRemote, type RemoteRun } from "./remote-run.js";
17
+ /** Thrown where a remote run reaches something only this machine's owner may use; the command ends refused. */
18
+ export declare class RemoteRefusal extends Error {
19
+ readonly reason: string;
20
+ constructor(reason: string, message: string);
21
+ }
22
+ export declare const REMOTE_MESSAGES: {
23
+ readonly "step-up": "Approvals, people and policy changes aren't taken from a token: approve in the console or chat.";
24
+ readonly no: "That command only runs on the server's own machine, not for a remote caller.";
25
+ readonly unknown: "That isn't a command a remote caller can run.";
26
+ readonly read: "Your token reads only. Use an act token for this command.";
27
+ readonly stale: "Your sign-in changed since this token was checked. Sign in again.";
28
+ readonly credential: "Remote commands run as your token's person: leave out --as, --token, --db and other credential or file flags.";
29
+ readonly project: "Name a project you have access to with --repo (its path on the server).";
30
+ readonly installation: "This command covers every project; it needs access to all projects.";
31
+ readonly files: "Send that file with the request; the server reads no paths of its own for you.";
32
+ };
33
+ /**
34
+ * How a "yes" command finds the project it touches, before it runs. Any --repo given is checked too.
35
+ * installation — reads or acts across every project: only for someone with access to all of them.
36
+ * repo — needs --repo, naming a project the server knows and the person may use.
37
+ * references — the audited arguments resolve every resource to its stored project before dispatch.
38
+ * self — the command already narrows to the person's own projects (their access is passed in).
39
+ * global — touches no project's data.
40
+ */
41
+ export type RemoteScope = {
42
+ kind: "installation";
43
+ } | {
44
+ kind: "repo";
45
+ } | {
46
+ kind: "references";
47
+ } | {
48
+ kind: "self";
49
+ } | {
50
+ kind: "global";
51
+ };
52
+ /** Every "yes" row's scope. The remote test holds this to the guide's "yes" rows exactly. */
53
+ export declare const REMOTE_SCOPES: ReadonlyMap<string, RemoteScope>;
54
+ /** Flags a remote caller never passes: who they are comes from the token, and the server reads and writes no paths for them. */
55
+ export declare const REMOTE_REFUSED_FLAGS: ReadonlySet<string>;
56
+ /**
57
+ * The guide row an argv is: the longest invocation its command and leading positionals spell. A command with no
58
+ * row of its own runs nowhere remotely (backup, restore, storage, ledger, policy and the rest).
59
+ */
60
+ export declare function remoteRowOf(command: string, positional: readonly string[]): CommandRow | null;
61
+ /**
62
+ * Re-prove the principal now, against the store: the account still stands at the same generation, the token is
63
+ * the person's, unrevoked and unexpired, and the scope is no wider than the token's or the person's role.
64
+ */
65
+ export declare function reproveRemote(store: Store, principal: Principal, now: Date): {
66
+ ok: true;
67
+ scope: "read" | "act";
68
+ tokenName: string;
69
+ } | {
70
+ ok: false;
71
+ };
72
+ /** Whether the person may use `repo`: inside their token's projects and their account's current access. Null is "every project". */
73
+ export declare function remoteAllows(store: Store, principal: Principal, repo: string | null): boolean;
74
+ /** The project a command touches, or why it may not run: --repo must be a project the server knows, by its exact path. */
75
+ export declare function remoteProjectOf(store: Store, run: {
76
+ allows: (repo: string | null) => boolean;
77
+ }, scope: RemoteScope, positional: readonly string[], repos: readonly string[], invocation: string, flags: ReadonlyMap<string, string | true>, files: Readonly<Record<string, string>>): {
78
+ ok: true;
79
+ repo: string | null;
80
+ taskId: string | null;
81
+ } | {
82
+ ok: false;
83
+ reason: string;
84
+ message: string;
85
+ };
86
+ export declare function remoteSecret(): string;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Running a command on a central server for a person who signed in with their own API token
3
+ * (`runOperateAs` in operate.ts). The machine contract is the allowlist: every row states `remote`
4
+ * (src/surface.ts), and only "yes" rows run. This module holds what that boundary decides before any
5
+ * command code runs — which row an argv is, whether the person and token still stand, and which
6
+ * project the command touches — and the per-run state the credential paths in operate.ts consult so
7
+ * a remote run is always the person and never this machine's owner.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ import { isAbsolute } from "node:path";
11
+ import { COMMAND_GUIDE } from "./surface.js";
12
+ import { remoteArgumentsOf, REMOTE_NOT_FOUND } from "./operate-remote-arguments.js";
13
+ export { activeRemote, withRemote } from "./remote-run.js";
14
+ /** Thrown where a remote run reaches something only this machine's owner may use; the command ends refused. */
15
+ export class RemoteRefusal extends Error {
16
+ reason;
17
+ constructor(reason, message) {
18
+ super(message);
19
+ this.reason = reason;
20
+ }
21
+ }
22
+ export const REMOTE_MESSAGES = {
23
+ "step-up": "Approvals, people and policy changes aren't taken from a token: approve in the console or chat.",
24
+ no: "That command only runs on the server's own machine, not for a remote caller.",
25
+ unknown: "That isn't a command a remote caller can run.",
26
+ read: "Your token reads only. Use an act token for this command.",
27
+ stale: "Your sign-in changed since this token was checked. Sign in again.",
28
+ credential: "Remote commands run as your token's person: leave out --as, --token, --db and other credential or file flags.",
29
+ project: "Name a project you have access to with --repo (its path on the server).",
30
+ installation: "This command covers every project; it needs access to all projects.",
31
+ files: "Send that file with the request; the server reads no paths of its own for you.",
32
+ };
33
+ const installation = { kind: "installation" }, repo = { kind: "repo" }, self = { kind: "self" }, global = { kind: "global" };
34
+ const references = { kind: "references" };
35
+ /** Every "yes" row's scope. The remote test holds this to the guide's "yes" rows exactly. */
36
+ export const REMOTE_SCOPES = new Map([
37
+ ["status", installation], ["integrations", installation], ["ready", installation], ["grants", installation], ["sync", installation],
38
+ ["gaps", repo], ["task add", repo],
39
+ ...["ask", "checks", "add-tests", "show", "wait", "complete", "revise", "state", "block", "unblock", "next", "steer", "assign", "scope", "plan", "hold",
40
+ "unhold", "require", "requeue", "review", "repair", "route", "reopen", "stop", "resume"].map(action => [`task ${action}`, references]),
41
+ ["check-progress", references],
42
+ ["task list", installation],
43
+ ["runner list", installation], ["coordinator list", installation], ["outbox list", installation], ["incident list", installation], ["incident resolve", installation],
44
+ ["cap list", repo], ["cap add", repo],
45
+ ...["list", "show", "add", "refresh", "pause", "resume", "run-now"].map(action => [`routine ${action}`, installation]),
46
+ ["config show", installation], ["verify show", repo], ["intake show", installation], ["intake run", installation], ["intake preview", installation],
47
+ ["intake pr-comments", installation], ["template list", installation], ["template show", installation], ["contest show", installation],
48
+ ["contest exclude", installation], ["webhook status", installation], ["webhook test", installation], ["review show", installation],
49
+ ["chat-approval show", self], ["chat-approval off", self],
50
+ ["knowledge search", repo], ["knowledge impact", repo], ["knowledge refresh", repo],
51
+ ...["search", "decisions", "show", "decide", "retire", "review", "status"].map(action => [`memory ${action}`, self]),
52
+ ...["list", "show", "export", "create", "edit", "trigger add", "trigger pause", "trigger resume", "trigger remove", "card add", "archive"]
53
+ .map(action => [`flows ${action}`, self]),
54
+ ["models status", global], ["models list", global],
55
+ ]);
56
+ /** Flags a remote caller never passes: who they are comes from the token, and the server reads and writes no paths for them. */
57
+ export const REMOTE_REFUSED_FLAGS = new Set([
58
+ "db", "as", "token", "token-file", "token-env", "password", "key-file", "credentials", "containment", "out", "project-root", "bin", "port", "host",
59
+ ]);
60
+ /**
61
+ * The guide row an argv is: the longest invocation its command and leading positionals spell. A command with no
62
+ * row of its own runs nowhere remotely (backup, restore, storage, ledger, policy and the rest).
63
+ */
64
+ export function remoteRowOf(command, positional) {
65
+ for (let words = Math.min(positional.length, 2); words >= 0; words -= 1) {
66
+ const invocation = [command, ...positional.slice(0, words)].join(" ");
67
+ const row = COMMAND_GUIDE.find(one => one.invocation === invocation && one.invocation !== "");
68
+ if (row !== undefined)
69
+ return row;
70
+ }
71
+ return null;
72
+ }
73
+ /**
74
+ * Re-prove the principal now, against the store: the account still stands at the same generation, the token is
75
+ * the person's, unrevoked and unexpired, and the scope is no wider than the token's or the person's role.
76
+ */
77
+ export function reproveRemote(store, principal, now) {
78
+ if (principal === null || typeof principal !== "object" || principal.kind !== "person" || typeof principal.account !== "string" || typeof principal.tokenId !== "string")
79
+ return { ok: false };
80
+ const account = store.accountOf(principal.account);
81
+ if (account === null || account.revokedAt !== null || account.generation !== principal.generation)
82
+ return { ok: false };
83
+ const token = store.apiTokenSecret(principal.tokenId)?.row ?? null;
84
+ if (token === null || token.account !== principal.account || token.revokedAt !== null || Date.parse(token.expiresAt) <= now.getTime())
85
+ return { ok: false };
86
+ const scope = principal.scope === "act" && token.access === "act" && account.role === "approver" ? "act" : "read";
87
+ return { ok: true, scope, tokenName: token.name };
88
+ }
89
+ /** Whether the person may use `repo`: inside their token's projects and their account's current access. Null is "every project". */
90
+ export function remoteAllows(store, principal, repo) {
91
+ if (principal.projects !== null && (repo === null || !principal.projects.includes(repo)))
92
+ return false;
93
+ return store.accountCanAccess(principal.account, repo);
94
+ }
95
+ /** The project a command touches, or why it may not run: --repo must be a project the server knows, by its exact path. */
96
+ export function remoteProjectOf(store, run, scope, positional, repos, invocation, flags, files) {
97
+ const known = store.knownRepos();
98
+ // The local parser keeps the last raw --repo as well as a split repoList. Require those identities
99
+ // to agree: comma lists and repeated project flags cannot authorize one path and dispatch another.
100
+ const rawRepo = flags.get("repo");
101
+ if (rawRepo !== undefined && (repos.length !== 1 || rawRepo !== repos[0]))
102
+ return { ok: false, reason: "unknown-project", message: REMOTE_MESSAGES.project };
103
+ for (const given of repos) {
104
+ if (!isAbsolute(given) || !known.includes(given) || !run.allows(given))
105
+ return { ok: false, reason: "unknown-project", message: REMOTE_MESSAGES.project };
106
+ }
107
+ const named = repos[0] ?? null;
108
+ const checked = remoteArgumentsOf(store, run.allows, invocation, positional, flags, named, files);
109
+ if (!checked.ok)
110
+ return checked;
111
+ switch (scope.kind) {
112
+ case "global":
113
+ case "self":
114
+ return checked;
115
+ case "installation":
116
+ return run.allows(null) ? { ok: true, repo: named, taskId: null } : { ok: false, reason: "all-projects", message: REMOTE_MESSAGES.installation };
117
+ case "repo":
118
+ return named === null ? { ok: false, reason: "unknown-project", message: REMOTE_MESSAGES.project } : checked;
119
+ case "references":
120
+ return checked.repo === null ? REMOTE_NOT_FOUND : checked;
121
+ }
122
+ }
123
+ export function remoteSecret() {
124
+ return `remote:${randomBytes(24).toString("base64url")}`;
125
+ }
package/dist/operate.d.ts CHANGED
@@ -8,6 +8,7 @@ import { type Store } from "./store.js";
8
8
  import { type DispatchAdapter } from "./sync.js";
9
9
  import { type TelegramTransport } from "./telegram.js";
10
10
  import { type MateCliSeams } from "./mate-cli.js";
11
+ import { type Principal, type RemoteSource } from "./operate-remote.js";
11
12
  import { type PublishExec } from "./publish.js";
12
13
  import { type Runner as CommandRunner } from "./builder.js";
13
14
  import { type ReleaseIo } from "./releases.js";
@@ -123,6 +124,26 @@ export declare function parseOperateArgs(argv: readonly string[], ownValues?: Re
123
124
  };
124
125
  /** Route an `operate` command. Returns the process exit code. */
125
126
  export declare function runOperate(command: string, argv: readonly string[], write: Write, options?: OperateOptions): Promise<number>;
127
+ export type { Principal };
128
+ /**
129
+ * Run one command on this server for a person who signed in with their own API token (the transport and MCP
130
+ * gateway prove the `so_` token and refuse browser origins; this is everything after). The command's row in the
131
+ * machine contract decides first: "no" and "step-up" rows, and commands with no row, are refused before any
132
+ * command code runs. The principal is re-proved against the store, a read token runs only reads, and the project
133
+ * the command touches must be one the person may use, named by --repo where a local run would use the working
134
+ * directory. The command then runs as that person: every credential check resolves to them, nothing prompts, and
135
+ * this machine's saved login and lead token are never read. Files are only those sent in `files`, keyed by the
136
+ * argument that names them. Every call is in the action history as the person, from source `api` (or `mcp`),
137
+ * with the token's name. The store is the caller's and stays open.
138
+ */
139
+ export declare function runOperateAs(argv: string[], opts: {
140
+ principal: Principal;
141
+ store: Store;
142
+ write: (s: string) => void;
143
+ files?: Record<string, string>;
144
+ source?: RemoteSource;
145
+ options?: OperateOptions;
146
+ }): Promise<number>;
126
147
  /** Durably create the login file BEFORE the bootstrap commits (arc 2
127
148
  * finding 27): exclusive, 0600, fsynced — file and directory both — so the
128
149
  * row only ever follows a durable secret. Throws on any failure. */
@@ -142,4 +163,3 @@ export declare function readUpConsoleUrl(dir: string): string | null;
142
163
  * missing credentials stay a usage refusal, exactly as before.
143
164
  */
144
165
  export declare const PROPOSALS_ACTIONS: readonly ["list", "confirm", "dismiss"];
145
- export {};