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.
- package/CHANGELOG.md +18 -0
- package/dist/action-ledger.d.ts +5 -2
- package/dist/action-ledger.js +13 -3
- package/dist/cli-http.d.ts +64 -0
- package/dist/cli-http.js +143 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +7 -0
- package/dist/contracts/cli.d.ts +13 -0
- package/dist/contracts/cli.js +7 -0
- package/dist/contracts/gateway-tools.d.ts +82 -0
- package/dist/contracts/gateway-tools.js +33 -0
- package/dist/desktop-update.d.ts +3 -1
- package/dist/desktop-update.js +7 -6
- package/dist/mcp-core.d.ts +110 -0
- package/dist/mcp-core.js +311 -0
- package/dist/mcp-http.d.ts +26 -0
- package/dist/mcp-http.js +151 -0
- package/dist/mcp-person.d.ts +31 -0
- package/dist/mcp-person.js +96 -0
- package/dist/mcp.d.ts +1 -8
- package/dist/mcp.js +21 -322
- package/dist/operate-remote-arguments.d.ts +36 -0
- package/dist/operate-remote-arguments.js +185 -0
- package/dist/operate-remote.d.ts +86 -0
- package/dist/operate-remote.js +125 -0
- package/dist/operate.d.ts +21 -1
- package/dist/operate.js +176 -12
- package/dist/remote-command.d.ts +45 -0
- package/dist/remote-command.js +124 -0
- package/dist/remote-exec.d.ts +18 -0
- package/dist/remote-exec.js +241 -0
- package/dist/remote-run.d.ts +16 -0
- package/dist/remote-run.js +13 -0
- package/dist/scope.js +10 -0
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +24 -0
- package/dist/store.d.ts +15 -1
- package/dist/store.js +34 -7
- package/dist/surface.d.ts +10 -3
- package/dist/surface.js +67 -2
- package/dist/team-cli.d.ts +19 -0
- package/dist/team-cli.js +20 -3
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.44 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **Work from your own laptop.** `toolroll connect https://… --token so_…`
|
|
6
|
+
and allowed commands run on the server as you, with the same output and
|
|
7
|
+
exit codes. Approvals, people, policy and anything touching the server's
|
|
8
|
+
files stay in the console and chat.
|
|
9
|
+
- **Agents connect over HTTPS.** `claude mcp add --transport http toolroll
|
|
10
|
+
https://<host>/mcp --header "Authorization: Bearer so_…"`. Tools cover only
|
|
11
|
+
that person's projects.
|
|
12
|
+
- **Every remote action is on the record**: who, whether it came from the CLI
|
|
13
|
+
or an agent, and the token's name. A revoked token stops on its next call.
|
|
14
|
+
Other projects' tasks and runs answer "not found".
|
|
15
|
+
- **For now, people limited to some projects** can't run the commands that
|
|
16
|
+
list across projects (`status`, `task list`) or choose a task's `--id`;
|
|
17
|
+
they're refused rather than shown a partial list. Filtered views are next.
|
|
18
|
+
- Still to come: token management in the CLI, rate limits, an audit view, a
|
|
19
|
+
real domain with TLS, and sign-in for agents.
|
|
20
|
+
|
|
3
21
|
## 0.9.43 — 2026-10-06
|
|
4
22
|
|
|
5
23
|
- **Approve from chat and the CLI.** A plan waiting on you and a flow's
|
package/dist/action-ledger.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Database } from "./store.js";
|
|
2
|
-
/** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event.
|
|
3
|
-
|
|
2
|
+
/** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event.
|
|
3
|
+
* v110: commands a person ran on this server with their API token, from the CLI ('api') or their agent ('mcp'). */
|
|
4
|
+
export declare const LEDGER_SOURCES: readonly ["work", "request", "access", "sign-in", "policy", "api", "mcp"];
|
|
4
5
|
export type LedgerSource = typeof LEDGER_SOURCES[number];
|
|
5
6
|
export type LedgerEntry = {
|
|
6
7
|
id: number;
|
|
@@ -18,6 +19,8 @@ export type LedgerEntry = {
|
|
|
18
19
|
/** The table as v54 made it: the one earlier shape the v99 rebuild accepts. */
|
|
19
20
|
export declare const LEDGER_V54_TABLE: (name: string) => string;
|
|
20
21
|
export declare const LEDGER_V54_COLUMNS: readonly ["id", "at", "actor", "repo", "task_id", "run_id", "action", "outcome", "source"];
|
|
22
|
+
/** The table as v99 made it: the v110 rebuild accepts this shape and v54's. */
|
|
23
|
+
export declare const LEDGER_V99_TABLE: (name: string) => string;
|
|
21
24
|
export declare const LEDGER_TABLE: (name: string) => string;
|
|
22
25
|
export declare const LEDGER_SCHEMA: string;
|
|
23
26
|
/** Installed after all table migrations. Work events commit with the work,
|
package/dist/action-ledger.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
/** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event.
|
|
2
|
-
|
|
1
|
+
/** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event.
|
|
2
|
+
* v110: commands a person ran on this server with their API token, from the CLI ('api') or their agent ('mcp'). */
|
|
3
|
+
export const LEDGER_SOURCES = ["work", "request", "access", "sign-in", "policy", "api", "mcp"];
|
|
3
4
|
/** The table as v54 made it: the one earlier shape the v99 rebuild accepts. */
|
|
4
5
|
export const LEDGER_V54_TABLE = (name) => `CREATE TABLE ${name} (
|
|
5
6
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -9,7 +10,8 @@ export const LEDGER_V54_TABLE = (name) => `CREATE TABLE ${name} (
|
|
|
9
10
|
source TEXT NOT NULL CHECK (source IN ('work','request','access'))
|
|
10
11
|
)`;
|
|
11
12
|
export const LEDGER_V54_COLUMNS = ["id", "at", "actor", "repo", "task_id", "run_id", "action", "outcome", "source"];
|
|
12
|
-
|
|
13
|
+
/** The table as v99 made it: the v110 rebuild accepts this shape and v54's. */
|
|
14
|
+
export const LEDGER_V99_TABLE = (name) => `CREATE TABLE ${name} (
|
|
13
15
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
16
|
at TEXT NOT NULL, actor TEXT NOT NULL, repo TEXT,
|
|
15
17
|
task_id TEXT, run_id INTEGER,
|
|
@@ -17,6 +19,14 @@ export const LEDGER_TABLE = (name) => `CREATE TABLE ${name} (
|
|
|
17
19
|
source TEXT NOT NULL CHECK (source IN ('work','request','access','sign-in','policy')),
|
|
18
20
|
detail TEXT
|
|
19
21
|
)`;
|
|
22
|
+
export const LEDGER_TABLE = (name) => `CREATE TABLE ${name} (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
at TEXT NOT NULL, actor TEXT NOT NULL, repo TEXT,
|
|
25
|
+
task_id TEXT, run_id INTEGER,
|
|
26
|
+
action TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
27
|
+
source TEXT NOT NULL CHECK (source IN ('work','request','access','sign-in','policy','api','mcp')),
|
|
28
|
+
detail TEXT
|
|
29
|
+
)`;
|
|
20
30
|
export const LEDGER_SCHEMA = `
|
|
21
31
|
${LEDGER_TABLE("IF NOT EXISTS action_ledger")};
|
|
22
32
|
CREATE INDEX IF NOT EXISTS action_ledger_project ON action_ledger(repo, id DESC);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
import { type RemoteCommandLookup } from './remote-command.js';
|
|
4
|
+
/**
|
|
5
|
+
* `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
|
|
6
|
+
* server runs that same command as that person. There is no per-resource API here and no second policy: the transport
|
|
7
|
+
* authenticates one API token, checks the shared command contract and transport arguments, and hands the argv to
|
|
8
|
+
* `runOperateAs`, which owns scope, project grants and attribution.
|
|
9
|
+
*
|
|
10
|
+
* Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
|
|
11
|
+
* carrying a browser Origin is refused, so a page can never drive it.
|
|
12
|
+
*/
|
|
13
|
+
/** Who a remote command runs as. The shared seam with operate.ts (remote-principal). */
|
|
14
|
+
export type Principal = {
|
|
15
|
+
kind: 'person';
|
|
16
|
+
account: string;
|
|
17
|
+
generation: number;
|
|
18
|
+
scope: 'read' | 'act';
|
|
19
|
+
tokenId: string;
|
|
20
|
+
projects: string[] | null;
|
|
21
|
+
};
|
|
22
|
+
export type RunOperateAs = (argv: string[], opts: {
|
|
23
|
+
principal: Principal;
|
|
24
|
+
store: Store;
|
|
25
|
+
write: (s: string) => void;
|
|
26
|
+
files?: Record<string, string>;
|
|
27
|
+
source?: 'api' | 'mcp';
|
|
28
|
+
}) => Promise<number>;
|
|
29
|
+
export type CliRequest = {
|
|
30
|
+
argv: string[];
|
|
31
|
+
files: Record<string, string>;
|
|
32
|
+
};
|
|
33
|
+
export type CliReply = {
|
|
34
|
+
exitCode: number;
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
};
|
|
38
|
+
export type CliFailure = {
|
|
39
|
+
ok: false;
|
|
40
|
+
code: string;
|
|
41
|
+
message: string;
|
|
42
|
+
};
|
|
43
|
+
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
44
|
+
export declare const CLI_FILES_BYTES: number;
|
|
45
|
+
/** The whole JSON body: the files plus argv and JSON escaping headroom. */
|
|
46
|
+
export declare const CLI_REQUEST_BYTES: number;
|
|
47
|
+
export type CliHttpOptions = {
|
|
48
|
+
/** The live API token's person, or null. Only called for a single, well-formed `Bearer so_…` header. */
|
|
49
|
+
authenticate: (request: IncomingMessage) => Principal | null;
|
|
50
|
+
/** The shared command boundary; null when this server cannot run remote commands. */
|
|
51
|
+
run: () => Promise<RunOperateAs | null>;
|
|
52
|
+
/** Tests: command policy while the parallel command contract is not yet available. Defaults to COMMAND_GUIDE. */
|
|
53
|
+
modeOf?: RemoteCommandLookup | undefined;
|
|
54
|
+
store: Store;
|
|
55
|
+
};
|
|
56
|
+
/** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
|
|
57
|
+
export declare function cliRequest(value: unknown): CliRequest | {
|
|
58
|
+
problem: string;
|
|
59
|
+
tooLarge?: true;
|
|
60
|
+
};
|
|
61
|
+
/** The longest one request may hold the server: the client polls again for longer waits. */
|
|
62
|
+
export declare const CLI_WAIT_SECONDS = 25;
|
|
63
|
+
/** Handles exactly `/api/cli`; false for any other path. */
|
|
64
|
+
export declare function handleCliHttp(request: IncomingMessage, response: ServerResponse, options: CliHttpOptions): Promise<boolean>;
|
package/dist/cli-http.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
|
|
2
|
+
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
3
|
+
export const CLI_FILES_BYTES = 256 * 1024;
|
|
4
|
+
/** The whole JSON body: the files plus argv and JSON escaping headroom. */
|
|
5
|
+
export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
|
|
6
|
+
const ARGV_MAX = 256;
|
|
7
|
+
const ARG_BYTES = 16 * 1024;
|
|
8
|
+
const FILES_MAX = 16;
|
|
9
|
+
function send(response, status, value) {
|
|
10
|
+
if (response.destroyed || response.writableEnded)
|
|
11
|
+
return;
|
|
12
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
13
|
+
response.end(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
async function body(request) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
for await (const chunk of request) {
|
|
19
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
20
|
+
size += bytes.length;
|
|
21
|
+
if (size > CLI_REQUEST_BYTES)
|
|
22
|
+
throw new Error('too-large');
|
|
23
|
+
chunks.push(bytes);
|
|
24
|
+
}
|
|
25
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
26
|
+
}
|
|
27
|
+
/** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
|
|
28
|
+
export function cliRequest(value) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
30
|
+
return { problem: 'Send {argv, files}.' };
|
|
31
|
+
const row = value;
|
|
32
|
+
if (!Object.keys(row).every(key => key === 'argv' || key === 'files'))
|
|
33
|
+
return { problem: 'Send only argv and files.' };
|
|
34
|
+
const argv = row.argv;
|
|
35
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.length > ARGV_MAX || !argv.every(one => typeof one === 'string' && Buffer.byteLength(one) <= ARG_BYTES && !one.includes('\0')))
|
|
36
|
+
return { problem: 'argv must be 1–256 strings.' };
|
|
37
|
+
const files = row.files ?? {};
|
|
38
|
+
if (!files || typeof files !== 'object' || Array.isArray(files))
|
|
39
|
+
return { problem: 'files must map an argument to its contents.' };
|
|
40
|
+
const entries = Object.entries(files);
|
|
41
|
+
if (entries.length > FILES_MAX)
|
|
42
|
+
return { problem: 'Too many files.' };
|
|
43
|
+
let size = 0;
|
|
44
|
+
const kept = Object.create(null);
|
|
45
|
+
for (const [key, content] of entries) {
|
|
46
|
+
if (typeof content !== 'string' || key === '' || key.includes('\0'))
|
|
47
|
+
return { problem: 'files must map file paths to text contents.' };
|
|
48
|
+
size += Buffer.byteLength(content);
|
|
49
|
+
if (size > CLI_FILES_BYTES)
|
|
50
|
+
return { problem: 'Files are limited to 256 KiB in total.', tooLarge: true };
|
|
51
|
+
kept[key] = content;
|
|
52
|
+
}
|
|
53
|
+
return { argv: argv, files: kept };
|
|
54
|
+
}
|
|
55
|
+
/** The longest one request may hold the server: the client polls again for longer waits. */
|
|
56
|
+
export const CLI_WAIT_SECONDS = 25;
|
|
57
|
+
function longPollBounded(argv) {
|
|
58
|
+
const words = argv.filter(one => !one.startsWith('-'));
|
|
59
|
+
if (words[0] !== 'task' || words[1] !== 'wait')
|
|
60
|
+
return true;
|
|
61
|
+
const at = argv.indexOf('--timeout'), given = at < 0 ? NaN : Number(argv[at + 1]);
|
|
62
|
+
return argv.filter(one => one === '--timeout').length === 1 && Number.isFinite(given) && given >= 0 && given <= CLI_WAIT_SECONDS;
|
|
63
|
+
}
|
|
64
|
+
/** Handles exactly `/api/cli`; false for any other path. */
|
|
65
|
+
export async function handleCliHttp(request, response, options) {
|
|
66
|
+
const url = new URL(request.url ?? '/', 'http://standing-orders.local');
|
|
67
|
+
if (url.pathname !== '/api/cli')
|
|
68
|
+
return false;
|
|
69
|
+
const reject = (status, code, message) => { send(response, status, { ok: false, code, message }); request.resume(); return true; };
|
|
70
|
+
if (url.search !== '' || url.username !== '' || url.password !== '')
|
|
71
|
+
return reject(400, 'credentials-in-url', 'Send the command in the request body; nothing travels in the URL.');
|
|
72
|
+
if (request.method !== 'POST')
|
|
73
|
+
return reject(405, 'method-not-allowed', 'Use POST.');
|
|
74
|
+
// A browser always names its page. The remote CLI never does, so a page can never drive this endpoint.
|
|
75
|
+
if (request.headers.origin !== undefined)
|
|
76
|
+
return reject(403, 'browser-origin', 'Remote commands come from the CLI, not a browser.');
|
|
77
|
+
const names = request.rawHeaders.filter((_value, index) => index % 2 === 0).map(name => name.toLowerCase());
|
|
78
|
+
const count = names.filter(name => name === 'authorization').length;
|
|
79
|
+
if (count > 1)
|
|
80
|
+
return reject(400, 'ambiguous-credentials', 'Send one authorization header.');
|
|
81
|
+
if (names.includes('cookie'))
|
|
82
|
+
return reject(400, 'cookie-refused', 'Remote commands sign in with an API token only.');
|
|
83
|
+
if (!/^Bearer so_\S+$/.test(request.headers.authorization ?? ''))
|
|
84
|
+
return reject(401, 'unauthenticated', 'Sign in with your API token: toolroll connect <origin> --token-stdin.');
|
|
85
|
+
if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
|
|
86
|
+
return reject(415, 'invalid-content-type', 'Send application/json.');
|
|
87
|
+
if (Number(request.headers['content-length'] ?? 0) > CLI_REQUEST_BYTES)
|
|
88
|
+
return reject(413, 'request-too-large', 'Files are limited to 256 KiB in total.');
|
|
89
|
+
let principal;
|
|
90
|
+
try {
|
|
91
|
+
principal = options.authenticate(request);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return reject(503, 'authentication-unavailable', 'Sign-in could not be checked.');
|
|
95
|
+
}
|
|
96
|
+
if (!principal)
|
|
97
|
+
return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
|
|
98
|
+
let value;
|
|
99
|
+
try {
|
|
100
|
+
value = await body(request);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return reject(error instanceof Error && error.message === 'too-large' ? 413 : 400, 'invalid-body', 'Send a valid JSON request; files are limited to 256 KiB in total.');
|
|
104
|
+
}
|
|
105
|
+
const parsed = cliRequest(value);
|
|
106
|
+
if ('problem' in parsed)
|
|
107
|
+
return reject(parsed.tooLarge ? 413 : 400, 'invalid-request', parsed.problem);
|
|
108
|
+
const row = (options.modeOf ?? contractRow)(parsed.argv);
|
|
109
|
+
if (row?.mode === 'step-up')
|
|
110
|
+
return reject(403, 'step-up', STEP_UP_MESSAGE);
|
|
111
|
+
if (row?.mode !== 'yes')
|
|
112
|
+
return reject(403, 'remote-refused', 'This command cannot run remotely.');
|
|
113
|
+
const paths = fileArguments(parsed.argv, row);
|
|
114
|
+
if ('problem' in paths)
|
|
115
|
+
return reject(403, paths.code, paths.problem);
|
|
116
|
+
const missing = paths.find(path => !Object.hasOwn(parsed.files, path));
|
|
117
|
+
if (missing !== undefined)
|
|
118
|
+
return reject(403, 'missing-file', 'Send every input file\'s contents in files; remote commands cannot read a server file.');
|
|
119
|
+
if (Object.keys(parsed.files).some(path => !paths.includes(path)))
|
|
120
|
+
return reject(400, 'invalid-request', 'Each file must name a declared input file argument.');
|
|
121
|
+
if (!longPollBounded(parsed.argv))
|
|
122
|
+
return reject(400, 'wait-too-long', `A remote task wait holds the server for at most ${CLI_WAIT_SECONDS} seconds; give --timeout ${CLI_WAIT_SECONDS} or less.`);
|
|
123
|
+
let run;
|
|
124
|
+
try {
|
|
125
|
+
run = await options.run();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
run = null;
|
|
129
|
+
}
|
|
130
|
+
if (run === null)
|
|
131
|
+
return reject(501, 'remote-unavailable', 'This server cannot run remote commands yet. Update it, or use --local.');
|
|
132
|
+
// Files stay in memory: runOperateAs reads them by their argument, so nothing is written to disk here.
|
|
133
|
+
const out = [];
|
|
134
|
+
let exitCode;
|
|
135
|
+
try {
|
|
136
|
+
exitCode = await run([...parsed.argv], { principal, store: options.store, write: line => { out.push(`${line}\n`); }, files: parsed.files, source: 'api' });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return reject(500, 'command-failed', 'The command stopped unexpectedly on the server. Check its state before running it again.');
|
|
140
|
+
}
|
|
141
|
+
send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { previewGithubRepo, cloneGithubRepo } from "./onboard.js";
|
|
14
14
|
import { type SessionCliOptions } from "./session-cli.js";
|
|
15
15
|
import { type TeamCliOptions } from "./team-cli.js";
|
|
16
|
+
import { type RemoteExecOptions } from "./remote-exec.js";
|
|
16
17
|
import { type UpdateCliDeps } from "./toolroll-update-cli.js";
|
|
17
18
|
import { type OperateOptions } from "./operate.js";
|
|
18
19
|
export type CliOptions = {
|
|
@@ -54,6 +55,8 @@ export type MainOptions = {
|
|
|
54
55
|
operate?: OperateOptions;
|
|
55
56
|
session?: SessionCliOptions;
|
|
56
57
|
team?: TeamCliOptions;
|
|
58
|
+
/** Injected by tests: the remote CLI's profile, fetch and output streams (defaults to `team`'s profile seams). */
|
|
59
|
+
remote?: RemoteExecOptions;
|
|
57
60
|
/** Injected by tests: the gh-facing halves of `repos add-from-github` —
|
|
58
61
|
* the verb's parsing, gating, and enrollment are what CLI tests prove;
|
|
59
62
|
* gh itself is proved by onboard.test.ts. */
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ import { run as execRun } from "./exec.js";
|
|
|
27
27
|
import { COMMAND_GUIDE, SURFACE_NOTES, SURFACE_SCHEMA_VERSION } from "./surface.js";
|
|
28
28
|
import { runSessionCommand, SESSION_CLI_ACTIONS } from "./session-cli.js";
|
|
29
29
|
import { maybeRunTeamCommand, TEAM_CLI_ACTIONS } from "./team-cli.js";
|
|
30
|
+
import { maybeRunRemoteCommand } from "./remote-exec.js";
|
|
30
31
|
import { discover, inspectAll } from "./discover.js";
|
|
31
32
|
import { readPulls } from "./pulls.js";
|
|
32
33
|
import { detectGraphs, chooseBackend, setupOptions, LABELS as GRAPH_LABELS, } from "./graph.js";
|
|
@@ -375,6 +376,12 @@ export async function main(argv, write = line => console.log(line), mainOptions
|
|
|
375
376
|
return code;
|
|
376
377
|
}
|
|
377
378
|
async function dispatch(argv, write, mainOptions) {
|
|
379
|
+
// A saved API token sends commands marked remote to the central server, before anything local opens.
|
|
380
|
+
const central = await maybeRunRemoteCommand(argv, mainOptions.remote ?? mainOptions.team);
|
|
381
|
+
if (typeof central === "number")
|
|
382
|
+
return central;
|
|
383
|
+
if (central !== null)
|
|
384
|
+
argv = central.local;
|
|
378
385
|
const team = await maybeRunTeamCommand(argv, write, mainOptions.team);
|
|
379
386
|
if (team !== null)
|
|
380
387
|
return team;
|
package/dist/contracts/cli.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ export declare const commandFlagSchema: z.ZodReadonly<z.ZodObject<{
|
|
|
25
25
|
meaning: z.ZodString;
|
|
26
26
|
}, z.core.$loose>>;
|
|
27
27
|
export type CommandFlag = z.infer<typeof commandFlagSchema>;
|
|
28
|
+
/** The remote policy every command row states; see `remote` below. */
|
|
29
|
+
export declare const REMOTE_POLICIES: readonly ["yes", "no", "step-up"];
|
|
30
|
+
export type RemotePolicy = typeof REMOTE_POLICIES[number];
|
|
28
31
|
export declare const commandRowSchema: z.ZodReadonly<z.ZodObject<{
|
|
29
32
|
invocation: z.ZodString;
|
|
30
33
|
envelopeCommand: z.ZodOptional<z.ZodString>;
|
|
@@ -82,6 +85,11 @@ export declare const commandRowSchema: z.ZodReadonly<z.ZodObject<{
|
|
|
82
85
|
const?: number;
|
|
83
86
|
}>;
|
|
84
87
|
}>>;
|
|
88
|
+
remote: z.ZodEnum<{
|
|
89
|
+
no: "no";
|
|
90
|
+
"step-up": "step-up";
|
|
91
|
+
yes: "yes";
|
|
92
|
+
}>;
|
|
85
93
|
}, z.core.$loose>>;
|
|
86
94
|
export type CommandRow = z.infer<typeof commandRowSchema>;
|
|
87
95
|
/**
|
|
@@ -157,6 +165,11 @@ export declare const COMMAND_OUTPUTS: {
|
|
|
157
165
|
const?: number;
|
|
158
166
|
}>;
|
|
159
167
|
}>>;
|
|
168
|
+
remote: z.ZodEnum<{
|
|
169
|
+
no: "no";
|
|
170
|
+
"step-up": "step-up";
|
|
171
|
+
yes: "yes";
|
|
172
|
+
}>;
|
|
160
173
|
}, z.core.$loose>>>;
|
|
161
174
|
}, z.core.$loose>, z.ZodObject<{
|
|
162
175
|
envelopeVersion: z.ZodLiteral<1>;
|
package/dist/contracts/cli.js
CHANGED
|
@@ -35,6 +35,8 @@ function answers(command, shape) {
|
|
|
35
35
|
}
|
|
36
36
|
// ---- the declared command guide, as `contract --commands` dumps it ----
|
|
37
37
|
export const commandFlagSchema = z.looseObject({ name: str, takesValue: z.boolean(), meaning: str }).readonly();
|
|
38
|
+
/** The remote policy every command row states; see `remote` below. */
|
|
39
|
+
export const REMOTE_POLICIES = ["yes", "no", "step-up"];
|
|
38
40
|
/** A session operation's executable input schema, as session-contract.ts states it. */
|
|
39
41
|
const sessionInputSchema = z.custom(value => typeof value === "object" && value !== null && !Array.isArray(value));
|
|
40
42
|
export const commandRowSchema = z.looseObject({
|
|
@@ -61,6 +63,11 @@ export const commandRowSchema = z.looseObject({
|
|
|
61
63
|
notableReasons: z.array(str).readonly().optional(),
|
|
62
64
|
/** Session schemas are executable input contracts, not a grant of authority. */
|
|
63
65
|
inputSchema: sessionInputSchema.optional(),
|
|
66
|
+
/** Whether a person signed in to a central server with their own API token may run it there (`runOperateAs`):
|
|
67
|
+
* yes — runs as that person, within their token's scope and project access.
|
|
68
|
+
* no — this machine's own infrastructure, credentials or files; never run for a remote caller.
|
|
69
|
+
* step-up — approvals, people and policy: a person's act in the console or chat, never a token's. */
|
|
70
|
+
remote: z.enum(REMOTE_POLICIES),
|
|
64
71
|
}).readonly();
|
|
65
72
|
// ---- the commands that describe their fields ----
|
|
66
73
|
const taskSchema = z.looseObject({ id: str, title: str, state: z.enum(LISTED_TASK_STATES), createdAt: str, updatedAt: str, priority: z.number() });
|
|
@@ -372,3 +372,85 @@ export declare const GATEWAY_TOOL_OUTPUTS: {
|
|
|
372
372
|
}, z.core.$loose>;
|
|
373
373
|
};
|
|
374
374
|
export type GatewayToolOutput<N extends GatewayToolName> = z.infer<(typeof GATEWAY_TOOL_OUTPUTS)[N]>;
|
|
375
|
+
export declare const PERSON_TOOL_INPUTS: {
|
|
376
|
+
readonly status: z.ZodObject<{}, z.core.$strict>;
|
|
377
|
+
readonly list_tasks: z.ZodObject<{
|
|
378
|
+
state: z.ZodOptional<z.ZodEnum<{
|
|
379
|
+
cancelled: "cancelled";
|
|
380
|
+
done: "done";
|
|
381
|
+
failed: "failed";
|
|
382
|
+
queued: "queued";
|
|
383
|
+
running: "running";
|
|
384
|
+
}>>;
|
|
385
|
+
repo: z.ZodOptional<z.ZodString>;
|
|
386
|
+
cursor: z.ZodOptional<z.ZodInt>;
|
|
387
|
+
limit: z.ZodOptional<z.ZodInt>;
|
|
388
|
+
}, z.core.$strict>;
|
|
389
|
+
readonly task_show: z.ZodObject<{
|
|
390
|
+
ref: z.ZodString;
|
|
391
|
+
}, z.core.$strict>;
|
|
392
|
+
readonly task_review: z.ZodObject<{
|
|
393
|
+
ref: z.ZodString;
|
|
394
|
+
run: z.ZodOptional<z.ZodInt>;
|
|
395
|
+
all: z.ZodOptional<z.ZodBoolean>;
|
|
396
|
+
}, z.core.$strict>;
|
|
397
|
+
readonly review_findings: z.ZodObject<{
|
|
398
|
+
run: z.ZodInt;
|
|
399
|
+
}, z.core.$strict>;
|
|
400
|
+
readonly file_task: z.ZodObject<{
|
|
401
|
+
repo: z.ZodString;
|
|
402
|
+
title: z.ZodString;
|
|
403
|
+
idempotency_key: z.ZodString;
|
|
404
|
+
deliverable: z.ZodOptional<z.ZodEnum<{
|
|
405
|
+
branch: "branch";
|
|
406
|
+
report: "report";
|
|
407
|
+
}>>;
|
|
408
|
+
}, z.core.$strict>;
|
|
409
|
+
};
|
|
410
|
+
export type PersonToolName = keyof typeof PERSON_TOOL_INPUTS;
|
|
411
|
+
export type PersonToolInput<N extends PersonToolName> = z.infer<(typeof PERSON_TOOL_INPUTS)[N]>;
|
|
412
|
+
export declare const PERSON_TOOL_OUTPUTS: {
|
|
413
|
+
readonly status: z.ZodObject<{
|
|
414
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
415
|
+
ok: z.ZodBoolean;
|
|
416
|
+
command: z.ZodString;
|
|
417
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
418
|
+
message: z.ZodOptional<z.ZodString>;
|
|
419
|
+
}, z.core.$loose>;
|
|
420
|
+
readonly list_tasks: z.ZodObject<{
|
|
421
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
422
|
+
ok: z.ZodBoolean;
|
|
423
|
+
command: z.ZodString;
|
|
424
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
425
|
+
message: z.ZodOptional<z.ZodString>;
|
|
426
|
+
}, z.core.$loose>;
|
|
427
|
+
readonly task_show: z.ZodObject<{
|
|
428
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
429
|
+
ok: z.ZodBoolean;
|
|
430
|
+
command: z.ZodString;
|
|
431
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
432
|
+
message: z.ZodOptional<z.ZodString>;
|
|
433
|
+
}, z.core.$loose>;
|
|
434
|
+
readonly task_review: z.ZodObject<{
|
|
435
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
436
|
+
ok: z.ZodBoolean;
|
|
437
|
+
command: z.ZodString;
|
|
438
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
439
|
+
message: z.ZodOptional<z.ZodString>;
|
|
440
|
+
}, z.core.$loose>;
|
|
441
|
+
readonly review_findings: z.ZodObject<{
|
|
442
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
443
|
+
ok: z.ZodBoolean;
|
|
444
|
+
command: z.ZodString;
|
|
445
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
446
|
+
message: z.ZodOptional<z.ZodString>;
|
|
447
|
+
}, z.core.$loose>;
|
|
448
|
+
readonly file_task: z.ZodObject<{
|
|
449
|
+
envelopeVersion: z.ZodLiteral<1>;
|
|
450
|
+
ok: z.ZodBoolean;
|
|
451
|
+
command: z.ZodString;
|
|
452
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
453
|
+
message: z.ZodOptional<z.ZodString>;
|
|
454
|
+
id: z.ZodOptional<z.ZodString>;
|
|
455
|
+
}, z.core.$loose>;
|
|
456
|
+
};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { TEXT_LIMITS } from "../text-limits.js";
|
|
13
|
+
import { envelopeSchema } from "./cli.js";
|
|
13
14
|
import { catchUpOutput, decisionOutput, decisionsOutput, LISTED_TASK_STATES, queueColumnsOutput, recapOutput, repositoryContextOutput, scopeText, touches } from "./lead-tools.js";
|
|
14
15
|
const text = (key) => z.string().max(TEXT_LIMITS[key]);
|
|
15
16
|
const ref = z.string().min(1).max(TEXT_LIMITS.taskRef);
|
|
@@ -92,3 +93,35 @@ export const GATEWAY_TOOL_OUTPUTS = {
|
|
|
92
93
|
get_contract: z.looseObject({ contract: str }),
|
|
93
94
|
file_proposal: z.looseObject({ ref: str, replayed: z.boolean(), admission: str }),
|
|
94
95
|
};
|
|
96
|
+
// ------------------------------------------------------------- a person's tools
|
|
97
|
+
/**
|
|
98
|
+
* The tools a PERSON's API token sees over the HTTP gateway (mcp-person.ts). Each call becomes the exact `toolroll`
|
|
99
|
+
* command line the same person could type, run on the server under their own principal (operate.ts `runOperateAs`):
|
|
100
|
+
* the command's own authorization, project grants and attribution apply, and its `--json` envelope is the answer.
|
|
101
|
+
* Two reads keep the coordinator's names and arguments; the rest are new. Every value that lands in a command line
|
|
102
|
+
* may not begin with "-", so an argument can never be read as a flag.
|
|
103
|
+
*/
|
|
104
|
+
const notFlag = (schema) => schema.regex(/^[^-]/, { error: "must not begin with -" });
|
|
105
|
+
const taskRef = notFlag(ref);
|
|
106
|
+
const runId = z.int().min(1);
|
|
107
|
+
export const PERSON_TOOL_INPUTS = {
|
|
108
|
+
status: GATEWAY_TOOL_INPUTS.status,
|
|
109
|
+
list_tasks: GATEWAY_TOOL_INPUTS.list_tasks,
|
|
110
|
+
task_show: z.strictObject({ ref: taskRef }),
|
|
111
|
+
task_review: z.strictObject({ ref: taskRef, run: runId.optional(), all: z.boolean().optional() }),
|
|
112
|
+
review_findings: z.strictObject({ run: runId }),
|
|
113
|
+
file_task: z.strictObject({
|
|
114
|
+
repo: notFlag(gatewayRepo), title: notFlag(text("taskTitle").min(1)), idempotency_key: notFlag(text("idempotencyKey").min(8)),
|
|
115
|
+
deliverable: z.enum(["branch", "report"]).optional(),
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
/** The command's `--json` envelope (contracts/cli.ts); a refusal is `ok: false` with its stable reason. */
|
|
119
|
+
const envelope = envelopeSchema;
|
|
120
|
+
export const PERSON_TOOL_OUTPUTS = {
|
|
121
|
+
status: envelope,
|
|
122
|
+
list_tasks: envelope,
|
|
123
|
+
task_show: envelope,
|
|
124
|
+
task_review: envelope,
|
|
125
|
+
review_findings: envelope,
|
|
126
|
+
file_task: envelope.extend({ id: str.optional() }),
|
|
127
|
+
};
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export declare const updateTerminal: (phase: string) => boolean;
|
|
|
28
28
|
/** Durable receipts are private and independent of the app being replaced. */
|
|
29
29
|
export declare function durableJson(file: string, value: unknown): void;
|
|
30
30
|
export declare function readUpdateJournal(stateDir: string, retainedReceipt?: string): UpdateJournal | null;
|
|
31
|
+
/** Which settled schema versions a caller works on: the desktop update, which never migrates, only this build's own. */
|
|
32
|
+
export type SchemaAccepted = (version: number | null) => boolean;
|
|
31
33
|
/** A finished run whose end Toolroll cannot show yet: in plain words, with the one command that clears it when a
|
|
32
34
|
* person can. */
|
|
33
35
|
export type LingeringRun = {
|
|
@@ -135,7 +137,7 @@ export declare function pauseUpdateRecovery(stateDir: string, id: string, detail
|
|
|
135
137
|
* reservation so no writer slips in between the snapshot and the copy. The
|
|
136
138
|
* copy drops the update's own admission pause. `inside` runs before the
|
|
137
139
|
* reservation is released, `after` once it is; returns the copy's hash. */
|
|
138
|
-
export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void): Promise<string>;
|
|
140
|
+
export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void, accepts?: SchemaAccepted): Promise<string>;
|
|
139
141
|
/** A SQLite OS lock releases on updater death. Each irreversible step has a
|
|
140
142
|
* preceding durable intent; recovery determines the actual side by hashes. */
|
|
141
143
|
export declare function runDesktopUpdate(stateDir: string, hooks?: UpdateHooks): Promise<void>;
|
package/dist/desktop-update.js
CHANGED
|
@@ -116,13 +116,14 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
|
|
|
116
116
|
}
|
|
117
117
|
return j;
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
const currentSchemaOnly = version => version === SCHEMA_VERSION;
|
|
120
|
+
function connect(file, readOnly = false, accepts = currentSchemaOnly) {
|
|
120
121
|
if (!existsSync(file) || lstatSync(file).isSymbolicLink())
|
|
121
122
|
throw Error("The task database is missing or linked. It was not recreated.");
|
|
122
123
|
const db = new (sqlite().DatabaseSync)(file, { readOnly });
|
|
123
124
|
db.exec("PRAGMA busy_timeout=1000");
|
|
124
125
|
const schema = readSchemaVersion(db);
|
|
125
|
-
if (!schema.ok || schema.version
|
|
126
|
+
if (!schema.ok || !accepts(schema.version)) {
|
|
126
127
|
db.close();
|
|
127
128
|
throw Error("This update needs the current database schema. Use the separate verified migration procedure; the installed app is unchanged.");
|
|
128
129
|
}
|
|
@@ -515,13 +516,13 @@ async function verifiedBackup(j) {
|
|
|
515
516
|
* reservation so no writer slips in between the snapshot and the copy. The
|
|
516
517
|
* copy drops the update's own admission pause. `inside` runs before the
|
|
517
518
|
* reservation is released, `after` once it is; returns the copy's hash. */
|
|
518
|
-
export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }) {
|
|
519
|
-
const db = connect(databaseFile);
|
|
519
|
+
export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }, accepts = currentSchemaOnly) {
|
|
520
|
+
const db = connect(databaseFile, false, accepts);
|
|
520
521
|
try {
|
|
521
522
|
db.exec("BEGIN IMMEDIATE");
|
|
522
523
|
// The write reservation prevents a concurrent writer; a separate read
|
|
523
524
|
// connection is required because SQLite cannot back up a write transaction.
|
|
524
|
-
const source = connect(databaseFile, true);
|
|
525
|
+
const source = connect(databaseFile, true, accepts);
|
|
525
526
|
let before;
|
|
526
527
|
try {
|
|
527
528
|
before = snapshot(source);
|
|
@@ -531,7 +532,7 @@ export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, i
|
|
|
531
532
|
source.close();
|
|
532
533
|
}
|
|
533
534
|
chmodSync(backupPath, 0o600);
|
|
534
|
-
const copied = connect(backupPath);
|
|
535
|
+
const copied = connect(backupPath, false, accepts);
|
|
535
536
|
try {
|
|
536
537
|
if (copied.prepare("PRAGMA integrity_check").get()?.integrity_check !== "ok" || copied.prepare("PRAGMA foreign_key_check").all().length !== 0 || snapshot(copied) !== before)
|
|
537
538
|
throw Error("Backup verification failed. The installed app is unchanged.");
|