toolroll 0.9.57 → 0.9.58
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 +8 -0
- package/dist/action-ledger.js +0 -4
- package/dist/actor.d.ts +0 -10
- package/dist/actor.js +1 -18
- package/dist/api-tokens.d.ts +33 -2
- package/dist/api-tokens.js +39 -11
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.js +3 -2
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/desktop-update.d.ts +1 -1
- package/dist/export.js +2 -2
- package/dist/mcp-http.js +5 -5
- package/dist/operate.js +2 -1
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/remote-command.js +0 -2
- package/dist/remote-exec.d.ts +19 -0
- package/dist/remote-exec.js +9 -8
- package/dist/request-budget.d.ts +1 -1
- package/dist/scope.js +2 -1
- package/dist/serve.js +15 -0
- package/dist/server/edge-refusal.d.ts +2 -0
- package/dist/server/edge-refusal.js +8 -6
- package/dist/server/guards.js +5 -4
- package/dist/server/http.d.ts +2 -0
- package/dist/server/remote.js +6 -13
- package/dist/server/route-table.js +0 -3
- package/dist/session-cli.d.ts +38 -9
- package/dist/session-cli.js +141 -154
- package/dist/session-contract.js +2 -2
- package/dist/session-remote.d.ts +19 -0
- package/dist/session-remote.js +86 -0
- package/dist/store.d.ts +28 -6
- package/dist/store.js +259 -93
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/toolroll-update.js +3 -1
- package/package.json +1 -1
- package/dist/session-http.d.ts +0 -24
- package/dist/session-http.js +0 -170
- package/dist/session-server.d.ts +0 -17
- package/dist/session-server.js +0 -39
package/dist/session-cli.js
CHANGED
|
@@ -1,19 +1,100 @@
|
|
|
1
1
|
import { open } from 'node:fs/promises';
|
|
2
2
|
import { envelopeJson } from './envelope.js';
|
|
3
|
-
import { SESSION_DESCRIPTORS, SESSION_PROMPT_BYTES,
|
|
4
|
-
|
|
3
|
+
import { SESSION_DESCRIPTORS, SESSION_PROMPT_BYTES, isSessionResponse, sessionCapabilities, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
|
|
4
|
+
import { centralProfile, UsageError } from './team-cli.js';
|
|
5
|
+
import { postCli } from './remote-exec.js';
|
|
6
|
+
import { TOKEN_REPLACEMENT } from './password-bearer.js';
|
|
7
|
+
/** The files key the prompt travels under: the server reads no paths, only what the request carries. */
|
|
8
|
+
export const SESSION_PROMPT_FILE = 'prompt';
|
|
5
9
|
export const SESSION_CLI_ACTIONS = ['capabilities', ...SESSION_DESCRIPTORS.map(one => one.operation)];
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
/** Before D7 the session client signed in with an operator password on every request. */
|
|
11
|
+
const RETIRED_FLAGS = new Set(['url', 'as', 'token-env', 'token-file']);
|
|
12
|
+
export const SESSION_TOKEN_REQUIRED = `Session commands use your saved API token. ${TOKEN_REPLACEMENT}, then save it with \`toolroll connect <origin> --as <account> --token-stdin\`.`;
|
|
13
|
+
/** The flags an operation takes: on this computer (where the prompt may come from a file or stdin, and a saved
|
|
14
|
+
* profile is chosen), or on the server (where the prompt is only ever the request's own file). */
|
|
15
|
+
export function sessionCliFlags(spec, side = 'client') {
|
|
16
|
+
const prompt = spec.operation === 'start' || spec.operation === 'send';
|
|
17
|
+
return { json: 'flag', help: 'flag', ...(side === 'client' ? { profile: 'value' } : {}),
|
|
18
|
+
...Object.fromEntries(Object.keys(spec.flags).map(name => [name, 'value'])),
|
|
19
|
+
...(prompt ? { file: 'value', ...(side === 'client' ? { stdin: 'flag' } : {}) } : {}) };
|
|
8
20
|
}
|
|
9
21
|
function help(spec) {
|
|
10
22
|
if (!spec)
|
|
11
|
-
return `toolroll session <operation>\n\n${SESSION_DESCRIPTORS.map(one => ` ${one.operation.padEnd(12)} ${one.synopsis}`).join('\n')}\n capabilities Show this client's schemas and authority requirements\n\nUse session <operation> --help for required flags.
|
|
23
|
+
return `toolroll session <operation>\n\n${SESSION_DESCRIPTORS.map(one => ` ${one.operation.padEnd(12)} ${one.synopsis}`).join('\n')}\n capabilities Show this client's schemas and authority requirements\n\nUse session <operation> --help for required flags. Sessions run on the server you connected to with your API token (toolroll connect); they need instance operator access.`;
|
|
12
24
|
const fields = spec.inputSchema.required.filter(one => !['version', 'sessionId', 'prompt'].includes(one));
|
|
13
25
|
const required = fields.map(field => Object.entries(spec.flags).find(([, value]) => value.field === field)?.[0]).filter(Boolean);
|
|
14
|
-
return `toolroll session ${spec.operation}${'sessionId' in spec.inputSchema.properties ? ' <session-id>' : ''}\n${spec.synopsis}.\n\
|
|
26
|
+
return `toolroll session ${spec.operation}${'sessionId' in spec.inputSchema.properties ? ' <session-id>' : ''}\n${spec.synopsis}.\n\n${required.length > 0 ? `Required:\n${required.map(name => ` --${name} <value>`).join('\n')}\n` : ''}${'prompt' in spec.inputSchema.properties ? ' --file <path> or --stdin (complete prompt, up to 64 KB)\n' : ''}${'expectedRevision' in spec.inputSchema.properties ? '\nUse the revision, nativeThreadId and turnId from session show. Use none for a null thread or turn.\n' : ''}\nOptions: ${Object.keys(sessionCliFlags(spec)).map(name => `--${name}`).join(' ')}\nRuns on the saved connection (or --profile <name>) over /api/cli with your API token. Mutations are never retried automatically.`;
|
|
15
27
|
}
|
|
16
|
-
|
|
28
|
+
/** One parser for both sides of /api/cli: `<operation> [<session-id>] --flag value …`. */
|
|
29
|
+
export function parseSessionArguments(argv, side) {
|
|
30
|
+
const operation = argv[0] ?? '';
|
|
31
|
+
const spec = sessionDescriptor(operation);
|
|
32
|
+
if (!spec)
|
|
33
|
+
return { problem: `Unknown session operation. Use: ${SESSION_CLI_ACTIONS.join(', ')}.` };
|
|
34
|
+
const allowed = sessionCliFlags(spec, side);
|
|
35
|
+
const flags = {};
|
|
36
|
+
const positionals = [];
|
|
37
|
+
for (let index = 1; index < argv.length; index++) {
|
|
38
|
+
const argument = argv[index];
|
|
39
|
+
if (!argument.startsWith('-')) {
|
|
40
|
+
positionals.push(argument);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const name = argument === '-h' ? 'help' : argument.startsWith('--') ? argument.slice(2) : '';
|
|
44
|
+
if (side === 'client' && RETIRED_FLAGS.has(name))
|
|
45
|
+
return { problem: SESSION_TOKEN_REQUIRED };
|
|
46
|
+
if (!Object.hasOwn(allowed, name))
|
|
47
|
+
return { problem: `Unknown option ${argument} for session ${operation}.` };
|
|
48
|
+
if (Object.hasOwn(flags, name))
|
|
49
|
+
return { problem: `Option --${name} was repeated.` };
|
|
50
|
+
if (allowed[name] === 'flag')
|
|
51
|
+
flags[name] = true;
|
|
52
|
+
else {
|
|
53
|
+
const value = argv[++index];
|
|
54
|
+
if (!value || value.startsWith('-'))
|
|
55
|
+
return { problem: `--${name} requires a value.` };
|
|
56
|
+
flags[name] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { spec, flags, positionals };
|
|
60
|
+
}
|
|
61
|
+
/** The validated request the parsed arguments and prompt spell. */
|
|
62
|
+
export function sessionRequestOf({ spec, flags, positionals }, prompt) {
|
|
63
|
+
const request = { version: 1 };
|
|
64
|
+
if ('sessionId' in spec.inputSchema.properties) {
|
|
65
|
+
if (positionals.length !== 1)
|
|
66
|
+
return { ok: false, message: 'Name exactly one session ID.' };
|
|
67
|
+
request['sessionId'] = positionals[0];
|
|
68
|
+
}
|
|
69
|
+
else if (positionals.length)
|
|
70
|
+
return { ok: false, message: 'This operation takes no positional arguments.' };
|
|
71
|
+
for (const [name, field] of Object.entries(spec.flags)) {
|
|
72
|
+
const raw = flags[name];
|
|
73
|
+
if (typeof raw !== 'string')
|
|
74
|
+
continue;
|
|
75
|
+
if (field.kind === 'integer' && !/^\d+$/.test(raw))
|
|
76
|
+
return { ok: false, message: `--${name} must be a whole number.` };
|
|
77
|
+
request[field.field] = field.kind === 'integer' ? Number(raw) : field.kind === 'nullable-id' && raw === 'none' ? null : raw;
|
|
78
|
+
}
|
|
79
|
+
if (prompt !== undefined)
|
|
80
|
+
request['prompt'] = prompt;
|
|
81
|
+
return validateSessionRequest(spec.operation, request);
|
|
82
|
+
}
|
|
83
|
+
/** The exact argv the server runs for a validated request: always --json, the prompt as the request's one file. */
|
|
84
|
+
export function sessionArgv(spec, request) {
|
|
85
|
+
const fields = request;
|
|
86
|
+
const argv = ['session', spec.operation];
|
|
87
|
+
if (typeof fields['sessionId'] === 'string')
|
|
88
|
+
argv.push(fields['sessionId']);
|
|
89
|
+
for (const [name, field] of Object.entries(spec.flags)) {
|
|
90
|
+
const value = fields[field.field];
|
|
91
|
+
if (value !== undefined)
|
|
92
|
+
argv.push(`--${name}`, value === null ? 'none' : String(value));
|
|
93
|
+
}
|
|
94
|
+
if (typeof fields['prompt'] === 'string')
|
|
95
|
+
argv.push('--file', SESSION_PROMPT_FILE);
|
|
96
|
+
argv.push('--json');
|
|
97
|
+
return argv;
|
|
17
98
|
}
|
|
18
99
|
async function readBoundedFile(path, limit, label) {
|
|
19
100
|
let file;
|
|
@@ -60,90 +141,6 @@ async function readStdin() {
|
|
|
60
141
|
throw new UsageError('The prompt must be UTF-8 text.');
|
|
61
142
|
}
|
|
62
143
|
}
|
|
63
|
-
/** Credentials are never sent to a URL with embedded authority, an API path,
|
|
64
|
-
* query, fragment or an insecure remote host. Fetch cannot follow redirects. */
|
|
65
|
-
export function sessionServiceOrigin(raw) {
|
|
66
|
-
let url;
|
|
67
|
-
try {
|
|
68
|
-
url = new URL(raw);
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
throw new UsageError('--url must be an HTTPS or loopback HTTP service origin.');
|
|
72
|
-
}
|
|
73
|
-
if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
|
|
74
|
-
throw new UsageError('--url must contain only the service origin, with no credentials, path, query or fragment.');
|
|
75
|
-
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)))
|
|
76
|
-
throw new UsageError('--url requires HTTPS except for loopback HTTP.');
|
|
77
|
-
return url.origin;
|
|
78
|
-
}
|
|
79
|
-
async function parseRequest(spec, flags, positionals, options) {
|
|
80
|
-
const value = (name) => typeof flags[name] === 'string' ? flags[name] : '';
|
|
81
|
-
const url = sessionServiceOrigin(value('url'));
|
|
82
|
-
const account = value('as');
|
|
83
|
-
if (!account || account.length > 200 || /[\s:\x00-\x1f\x7f]/.test(account))
|
|
84
|
-
throw new UsageError('--as must name the operator account.');
|
|
85
|
-
if (Boolean(flags['token-env']) === Boolean(flags['token-file']))
|
|
86
|
-
throw new UsageError('Choose exactly one credential source: --token-env <variable-name> or --token-file <path>.');
|
|
87
|
-
let token;
|
|
88
|
-
if (flags['token-env']) {
|
|
89
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value('token-env')))
|
|
90
|
-
throw new UsageError('--token-env must name an environment variable.');
|
|
91
|
-
token = (options.env ?? process.env)[value('token-env')] ?? '';
|
|
92
|
-
}
|
|
93
|
-
else
|
|
94
|
-
token = (await readBoundedFile(value('token-file'), 8192, 'The token file')).trim();
|
|
95
|
-
if (!token || token.length > 8192 || /[\s\x00-\x1f\x7f]/.test(token))
|
|
96
|
-
throw new UsageError('The selected credential is empty or invalid. Set the named variable or token file.');
|
|
97
|
-
const request = { version: 1 };
|
|
98
|
-
if ('sessionId' in spec.inputSchema.properties) {
|
|
99
|
-
if (positionals.length !== 1)
|
|
100
|
-
throw new UsageError('Name exactly one session ID.');
|
|
101
|
-
request['sessionId'] = positionals[0];
|
|
102
|
-
}
|
|
103
|
-
else if (positionals.length)
|
|
104
|
-
throw new UsageError('This operation takes no positional arguments.');
|
|
105
|
-
for (const [name, field] of Object.entries(spec.flags)) {
|
|
106
|
-
if (flags[name] === undefined)
|
|
107
|
-
continue;
|
|
108
|
-
const raw = value(name);
|
|
109
|
-
if (field.kind === 'integer' && !/^\d+$/.test(raw))
|
|
110
|
-
throw new UsageError(`--${name} must be a whole number.`);
|
|
111
|
-
request[field.field] = field.kind === 'integer' ? Number(raw) : field.kind === 'nullable-id' && raw === 'none' ? null : raw;
|
|
112
|
-
}
|
|
113
|
-
if ('prompt' in spec.inputSchema.properties) {
|
|
114
|
-
if (Boolean(flags['file']) === Boolean(flags['stdin']))
|
|
115
|
-
throw new UsageError('Choose exactly one prompt source: --file <path> or --stdin.');
|
|
116
|
-
request['prompt'] = flags['file'] ? await readBoundedFile(value('file'), SESSION_PROMPT_BYTES, 'The prompt file') : await (options.readStdin ?? readStdin)();
|
|
117
|
-
}
|
|
118
|
-
const validated = validateSessionRequest(spec.operation, request);
|
|
119
|
-
if (!validated.ok)
|
|
120
|
-
throw new UsageError(validated.message);
|
|
121
|
-
return { spec, request: validated.request, url, account, token };
|
|
122
|
-
}
|
|
123
|
-
async function readResponse(response) {
|
|
124
|
-
if (!response.body)
|
|
125
|
-
throw new Error('Missing response body');
|
|
126
|
-
const reader = response.body.getReader();
|
|
127
|
-
const chunks = [];
|
|
128
|
-
let bytes = 0;
|
|
129
|
-
try {
|
|
130
|
-
while (true) {
|
|
131
|
-
const chunk = await reader.read();
|
|
132
|
-
if (chunk.done)
|
|
133
|
-
break;
|
|
134
|
-
bytes += chunk.value.byteLength;
|
|
135
|
-
if (bytes > SESSION_RESPONSE_BYTES) {
|
|
136
|
-
await reader.cancel();
|
|
137
|
-
throw new Error('Response limit');
|
|
138
|
-
}
|
|
139
|
-
chunks.push(chunk.value);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
finally {
|
|
143
|
-
reader.releaseLock();
|
|
144
|
-
}
|
|
145
|
-
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
146
|
-
}
|
|
147
144
|
function unconfirmed(spec, request) {
|
|
148
145
|
const sessionId = 'sessionId' in request ? request.sessionId : undefined;
|
|
149
146
|
return { version: 1, operation: spec.operation, ok: false, status: spec.mutation ? 'uncertain' : 'rejected',
|
|
@@ -152,42 +149,39 @@ function unconfirmed(spec, request) {
|
|
|
152
149
|
message: spec.mutation ? 'The service response could not be confirmed. Inspect the saved session before continuing; the request was not retried.' : 'The service response could not be read. Check the service address and connection.',
|
|
153
150
|
nextActions: [{ operation: sessionId ? 'show' : 'list', label: sessionId ? 'Inspect session' : 'List sessions', ...(sessionId ? { sessionId } : {}) }] };
|
|
154
151
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return unconfirmed(spec, request);
|
|
165
|
-
}
|
|
166
|
-
// This marker belongs to the owner boundary. A bare gateway 503 or 404
|
|
167
|
-
// cannot prove that an upstream mutation was never accepted.
|
|
168
|
-
if ([401, 403, 404, 503].includes(response.status) && response.headers.get('x-standing-orders-session-delivery') === 'not-sent') {
|
|
169
|
-
await response.body?.cancel();
|
|
170
|
-
const reason = response.status === 401 ? 'unauthenticated' : response.status === 403 ? 'forbidden' : response.status === 404 ? 'session-unsupported' : 'session-unavailable';
|
|
171
|
-
return { version: 1, operation: spec.operation, ok: false, status: 'rejected', delivery: 'not-sent', retry: spec.mutation ? 'never' : 'safe-read', reason,
|
|
172
|
-
message: response.status === 401 || response.status === 403 ? 'The service refused these operator credentials. Check the account and its access.' : 'This service does not currently support the requested session operation. Check its installed build and readiness.', nextActions: [] };
|
|
173
|
-
}
|
|
174
|
-
const payload = await readResponse(response);
|
|
175
|
-
if (!isSessionResponse(payload, spec.operation) || (payload.ok && !response.ok))
|
|
176
|
-
return unconfirmed(spec, request);
|
|
177
|
-
if ('sessionId' in request && payload.result?.session && payload.result.session.id !== request.sessionId)
|
|
178
|
-
return unconfirmed(spec, request);
|
|
179
|
-
if (payload.ok && 'key' in request && payload.result?.receipt?.key !== request.key)
|
|
180
|
-
return unconfirmed(spec, request);
|
|
181
|
-
if (payload.ok && 'expectedThreadId' in request && request.expectedThreadId !== null && payload.result?.session?.nativeThreadId !== request.expectedThreadId)
|
|
152
|
+
/** One request over /api/cli, never retried. Only an answer whose identity matches the request counts as delivered. */
|
|
153
|
+
async function perform(spec, request, profile, options) {
|
|
154
|
+
const files = typeof request.prompt === 'string' ? { [SESSION_PROMPT_FILE]: request.prompt } : {};
|
|
155
|
+
const outcome = await postCli(profile.origin, profile.token, { argv: sessionArgv(spec, request), files }, options);
|
|
156
|
+
if (outcome.kind === 'unconfirmed')
|
|
157
|
+
return unconfirmed(spec, request);
|
|
158
|
+
if (outcome.kind === 'refused') {
|
|
159
|
+
// /api/cli refuses before it runs anything, except a command that stopped on the server: that one may have acted.
|
|
160
|
+
if (outcome.status >= 500 && outcome.status !== 501 && outcome.status !== 503)
|
|
182
161
|
return unconfirmed(spec, request);
|
|
183
|
-
return
|
|
162
|
+
return { version: 1, operation: spec.operation, ok: false, status: 'rejected', delivery: 'not-sent', retry: spec.mutation ? 'never' : 'safe-read',
|
|
163
|
+
reason: /^[a-z][a-z0-9-]*$/.test(outcome.code) ? outcome.code : 'refused', message: outcome.message, nextActions: [] };
|
|
164
|
+
}
|
|
165
|
+
let payload;
|
|
166
|
+
try {
|
|
167
|
+
payload = JSON.parse(outcome.answer.stdout);
|
|
184
168
|
}
|
|
185
169
|
catch {
|
|
186
170
|
return unconfirmed(spec, request);
|
|
187
171
|
}
|
|
172
|
+
if (!isSessionResponse(payload, spec.operation))
|
|
173
|
+
return unconfirmed(spec, request);
|
|
174
|
+
if ('sessionId' in request && payload.result?.session && payload.result.session.id !== request.sessionId)
|
|
175
|
+
return unconfirmed(spec, request);
|
|
176
|
+
if (payload.ok && 'key' in request && payload.result?.receipt?.key !== request.key)
|
|
177
|
+
return unconfirmed(spec, request);
|
|
178
|
+
if (payload.ok && 'expectedThreadId' in request && request.expectedThreadId !== null && payload.result?.session?.nativeThreadId !== request.expectedThreadId)
|
|
179
|
+
return unconfirmed(spec, request);
|
|
180
|
+
return payload;
|
|
188
181
|
}
|
|
189
182
|
const terminalText = (value) => value.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
190
|
-
|
|
183
|
+
/** The plain-text answer, the same on either side of the transport. */
|
|
184
|
+
export function renderSessionResponse(response) {
|
|
191
185
|
const result = response.result;
|
|
192
186
|
const lines = response.ok && response.operation === 'show' && result?.brief ? [] : [response.message];
|
|
193
187
|
for (const session of result?.sessions ?? [])
|
|
@@ -210,6 +204,8 @@ function render(response) {
|
|
|
210
204
|
lines.push(`${action.label}: ${action.operation === 'open-ui' ? 'open the coding workspace' : `toolroll session ${action.operation}${action.sessionId ? ` ${action.sessionId}` : ''}`}`);
|
|
211
205
|
return terminalText(lines.join('\n'));
|
|
212
206
|
}
|
|
207
|
+
/** 0 delivered · 1 unknown (inspect first) · 3 refused. */
|
|
208
|
+
export const sessionExitCode = (response) => response.ok ? 0 : response.status === 'uncertain' || response.reason === 'service-unavailable' ? 1 : 3;
|
|
213
209
|
export async function runSessionCommand(argv, write, options = {}) {
|
|
214
210
|
const json = argv.includes('--json');
|
|
215
211
|
const stderr = options.stderr ?? (line => process.stderr.write(`${line}\n`));
|
|
@@ -236,49 +232,40 @@ export async function runSessionCommand(argv, write, options = {}) {
|
|
|
236
232
|
write(json ? envelopeJson({ ok: true, command, ...sessionCapabilities() }) : help());
|
|
237
233
|
return 0;
|
|
238
234
|
}
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
return fail(
|
|
242
|
-
const
|
|
243
|
-
const flags = {};
|
|
244
|
-
const positionals = [];
|
|
245
|
-
for (let index = 1; index < argv.length; index++) {
|
|
246
|
-
const argument = argv[index];
|
|
247
|
-
if (!argument.startsWith('-')) {
|
|
248
|
-
positionals.push(argument);
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
const name = argument === '-h' ? 'help' : argument.startsWith('--') ? argument.slice(2) : '';
|
|
252
|
-
if (!Object.hasOwn(allowed, name))
|
|
253
|
-
return fail(`Unknown option ${argument} for session ${operation}.`);
|
|
254
|
-
if (Object.hasOwn(flags, name))
|
|
255
|
-
return fail(`Option --${name} was repeated.`);
|
|
256
|
-
if (allowed[name] === 'flag')
|
|
257
|
-
flags[name] = true;
|
|
258
|
-
else {
|
|
259
|
-
const value = argv[++index];
|
|
260
|
-
if (!value || value.startsWith('-'))
|
|
261
|
-
return fail(`--${name} requires a value.`);
|
|
262
|
-
flags[name] = value;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
235
|
+
const parsed = parseSessionArguments(argv, 'client');
|
|
236
|
+
if ('problem' in parsed)
|
|
237
|
+
return fail(parsed.problem);
|
|
238
|
+
const { spec, flags } = parsed;
|
|
265
239
|
if (flags['help']) {
|
|
266
240
|
write(json ? envelopeJson({ ok: true, command, descriptor: spec, help: help(spec) }) : help(spec));
|
|
267
241
|
return 0;
|
|
268
242
|
}
|
|
269
|
-
let
|
|
243
|
+
let request;
|
|
244
|
+
let profile;
|
|
270
245
|
try {
|
|
271
|
-
|
|
246
|
+
let prompt;
|
|
247
|
+
if ('prompt' in spec.inputSchema.properties) {
|
|
248
|
+
if (Boolean(flags['file']) === Boolean(flags['stdin']))
|
|
249
|
+
throw new UsageError('Choose exactly one prompt source: --file <path> or --stdin.');
|
|
250
|
+
prompt = typeof flags['file'] === 'string' ? await readBoundedFile(flags['file'], SESSION_PROMPT_BYTES, 'The prompt file') : await (options.readStdin ?? readStdin)();
|
|
251
|
+
}
|
|
252
|
+
const validated = sessionRequestOf(parsed, prompt);
|
|
253
|
+
if (!validated.ok)
|
|
254
|
+
throw new UsageError(validated.message);
|
|
255
|
+
request = validated.request;
|
|
256
|
+
profile = centralProfile(options, typeof flags['profile'] === 'string' ? flags['profile'] : undefined);
|
|
272
257
|
}
|
|
273
258
|
catch (error) {
|
|
274
|
-
return fail(error instanceof UsageError ? error.message : 'The session request could not be prepared. Check the prompt and
|
|
259
|
+
return fail(error instanceof UsageError ? error.message : 'The session request could not be prepared. Check the prompt and saved connection.');
|
|
275
260
|
}
|
|
276
|
-
|
|
261
|
+
if (profile === null || !profile.apiToken)
|
|
262
|
+
return fail(SESSION_TOKEN_REQUIRED);
|
|
263
|
+
const response = await perform(spec, request, profile, options);
|
|
277
264
|
if (json)
|
|
278
|
-
write(envelopeJson({ ...response, command, ...('key' in
|
|
265
|
+
write(envelopeJson({ ...response, command, ...('key' in request ? { key: request.key } : {}) }));
|
|
279
266
|
else if (response.ok)
|
|
280
|
-
write(
|
|
267
|
+
write(renderSessionResponse(response));
|
|
281
268
|
else
|
|
282
|
-
stderr(
|
|
283
|
-
return response
|
|
269
|
+
stderr(renderSessionResponse(response));
|
|
270
|
+
return sessionExitCode(response);
|
|
284
271
|
}
|
package/dist/session-contract.js
CHANGED
|
@@ -114,8 +114,8 @@ export function isSessionResponse(value, operation) {
|
|
|
114
114
|
return true;
|
|
115
115
|
}
|
|
116
116
|
export function sessionCapabilities() {
|
|
117
|
-
return { version: SESSION_PROTOCOL_VERSION, transport: 'POST /api/
|
|
118
|
-
authority: '
|
|
117
|
+
return { version: SESSION_PROTOCOL_VERSION, transport: 'POST /api/cli with argv session <operation> (the prompt travels as the request\'s one file)', authentication: 'API token: Authorization Bearer so_… (toolroll tokens create, saved by toolroll connect)',
|
|
118
|
+
authority: 'An instance operator\'s API token without a project limit reads sessions; changing one needs an act token. Lead and coordinator credentials have no session access.',
|
|
119
119
|
operations: SESSION_DESCRIPTORS, brief: { supported: true, operation: 'show', defaultView: 'brief', source: 'Saved state and labeled agent reports, pinned to a revision; no additional model call.' },
|
|
120
120
|
unsupported: ['events', 'review', 'continue'], limits: { promptBytes: SESSION_PROMPT_BYTES, requestBytes: SESSION_REQUEST_BYTES, responseBytes: SESSION_RESPONSE_BYTES },
|
|
121
121
|
retry: 'Never automatically retry a mutation. Inspect saved state after a lost response; keep the original key.' };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
import type { CodingWorkspace } from './coding-workspace.js';
|
|
3
|
+
import { type Principal } from './operate-remote.js';
|
|
4
|
+
/**
|
|
5
|
+
* The server half of `toolroll session …` over `POST /api/cli` (D7, replacing the separate /api/sessions transport and
|
|
6
|
+
* its password sign-in). /api/cli has already proved the API token and charged its budget; this binds the token's
|
|
7
|
+
* person to the existing session owner, which rechecks the account and project on every operation. Native sessions
|
|
8
|
+
* stay installation-wide operator authority: only an instance operator's act token, limited to no projects, may
|
|
9
|
+
* change one. Every request is in the action ledger like any remote command, and every answer is one session
|
|
10
|
+
* envelope: a mutation whose outcome is unknown says so, and is never retried here.
|
|
11
|
+
*/
|
|
12
|
+
export type SessionRunner = (principal: Principal, argv: readonly string[], files: Readonly<Record<string, string>>, write: (line: string) => void) => Promise<number>;
|
|
13
|
+
export declare function createSessionRunner(options: {
|
|
14
|
+
store: Store;
|
|
15
|
+
workspace: CodingWorkspace | null;
|
|
16
|
+
projects: () => readonly string[];
|
|
17
|
+
projectAllowed: (repo: string) => boolean;
|
|
18
|
+
clock?: () => Date;
|
|
19
|
+
}): SessionRunner;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { envelopeJson } from './envelope.js';
|
|
3
|
+
import { REMOTE_MESSAGES, reproveRemote } from './operate-remote.js';
|
|
4
|
+
import { SESSION_RESPONSE_BYTES, isSessionResponse } from './session-contract.js';
|
|
5
|
+
import { parseSessionArguments, renderSessionResponse, sessionExitCode, sessionRequestOf } from './session-cli.js';
|
|
6
|
+
import { SessionService } from './session-service.js';
|
|
7
|
+
export function createSessionRunner(options) {
|
|
8
|
+
const { store } = options;
|
|
9
|
+
const clock = options.clock ?? (() => new Date());
|
|
10
|
+
const authorized = (actor, repo) => {
|
|
11
|
+
const account = store.accountOf(actor.name);
|
|
12
|
+
return account !== null && account.revokedAt === null && account.generation === actor.generation && store.isInstanceOperator(actor.name)
|
|
13
|
+
&& (repo === undefined || (options.projectAllowed(repo) && options.projects().includes(repo)));
|
|
14
|
+
};
|
|
15
|
+
const service = options.workspace === null || store.isDemo() ? null : new SessionService({ workspace: options.workspace, authorized,
|
|
16
|
+
project: (actor, input) => {
|
|
17
|
+
const path = realpathSync(input);
|
|
18
|
+
if (!authorized(actor, path) || !options.projects().includes(path))
|
|
19
|
+
throw Error('Choose a project already admitted to this installation.');
|
|
20
|
+
return path;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return async (principal, argv, files, write) => {
|
|
24
|
+
const json = argv.includes('--json');
|
|
25
|
+
const parsed = parseSessionArguments(argv.slice(1), 'server');
|
|
26
|
+
const operation = 'problem' in parsed ? null : parsed.spec.operation;
|
|
27
|
+
const command = `session ${operation ?? '(unknown)'}`;
|
|
28
|
+
let tokenName = null;
|
|
29
|
+
const record = (outcome, repo, why) => {
|
|
30
|
+
store.recordAction({ at: clock().toISOString(), actor: principal.account, repo, taskId: null, runId: null, action: `remote command: ${command}`, outcome, source: 'api',
|
|
31
|
+
detail: `token ${tokenName ?? principal.tokenId}${why === null ? '' : ` · ${why}`}` });
|
|
32
|
+
};
|
|
33
|
+
const answer = (response) => {
|
|
34
|
+
write(json ? envelopeJson({ ...response, command }) : renderSessionResponse(response));
|
|
35
|
+
return sessionExitCode(response);
|
|
36
|
+
};
|
|
37
|
+
const refuse = (spec, reason, message) => {
|
|
38
|
+
record('refused', null, reason);
|
|
39
|
+
if (spec === null) {
|
|
40
|
+
write(json ? envelopeJson({ ok: false, command, reason, message }) : message);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
return answer({ version: 1, operation: spec.operation, ok: false, status: 'rejected', delivery: 'not-sent', retry: spec.mutation ? 'never' : 'safe-read', reason, message, nextActions: [] });
|
|
44
|
+
};
|
|
45
|
+
const proved = reproveRemote(store, principal, clock());
|
|
46
|
+
if (!proved.ok)
|
|
47
|
+
return refuse('problem' in parsed ? null : parsed.spec, 'unauthenticated', REMOTE_MESSAGES.stale);
|
|
48
|
+
tokenName = proved.tokenName;
|
|
49
|
+
if ('problem' in parsed)
|
|
50
|
+
return refuse(null, 'usage', parsed.problem);
|
|
51
|
+
const { spec } = parsed;
|
|
52
|
+
if (spec.mutation && proved.scope !== 'act')
|
|
53
|
+
return refuse(spec, 'read-only', REMOTE_MESSAGES.read);
|
|
54
|
+
// A session reaches every project this installation admits: a project-limited token never stands for that.
|
|
55
|
+
if (principal.projects !== null || !store.isInstanceOperator(principal.account))
|
|
56
|
+
return refuse(spec, 'all-projects', 'Native sessions need instance operator access to every project. Use a token without a project limit.');
|
|
57
|
+
const file = parsed.flags['file'];
|
|
58
|
+
if (file !== undefined && (typeof file !== 'string' || !Object.hasOwn(files, file)))
|
|
59
|
+
return refuse(spec, 'usage', REMOTE_MESSAGES.files);
|
|
60
|
+
const validated = sessionRequestOf(parsed, typeof file === 'string' ? files[file] : undefined);
|
|
61
|
+
if (!validated.ok)
|
|
62
|
+
return refuse(spec, 'invalid-request', validated.message);
|
|
63
|
+
if (service === null)
|
|
64
|
+
return refuse(spec, 'session-unavailable', 'The session owner is not ready.');
|
|
65
|
+
const repo = 'project' in validated.request && spec.operation === 'start' ? validated.request.project : null;
|
|
66
|
+
record('requested', repo, null);
|
|
67
|
+
const uncertain = (reason, message) => {
|
|
68
|
+
const sessionId = 'sessionId' in validated.request ? validated.request.sessionId : undefined;
|
|
69
|
+
return { version: 1, operation: spec.operation, ok: false, status: 'uncertain', delivery: 'unknown', retry: 'inspect-first', reason, message,
|
|
70
|
+
nextActions: [{ operation: sessionId ? 'show' : 'list', label: sessionId ? 'Inspect session' : 'Inspect saved sessions', ...(sessionId ? { sessionId } : {}) }] };
|
|
71
|
+
};
|
|
72
|
+
let reply;
|
|
73
|
+
try {
|
|
74
|
+
reply = await service.execute({ name: principal.account, generation: principal.generation }, spec.operation, validated.request);
|
|
75
|
+
if (!isSessionResponse(reply, spec.operation))
|
|
76
|
+
throw new Error('Invalid owner response');
|
|
77
|
+
if (Buffer.byteLength(JSON.stringify(reply)) > SESSION_RESPONSE_BYTES)
|
|
78
|
+
reply = uncertain('response-too-large', 'The owner response is too large to deliver. Inspect saved activity before continuing; this request was not retried.');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
reply = uncertain('unconfirmed-response', 'The owner response could not be confirmed. Inspect saved activity before continuing; this request was not retried.');
|
|
82
|
+
}
|
|
83
|
+
record(reply.ok ? 'done' : reply.status === 'uncertain' ? 'failed' : 'refused', repo, reply.ok ? null : reply.reason ?? null);
|
|
84
|
+
return answer(reply);
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type SavedToolAction, type SavedToolRule } from "./contracts/store-json
|
|
|
7
7
|
import type { FlowCardChange, FlowCardFields } from "./contracts/flow-card.js";
|
|
8
8
|
import { type Actor } from "./actor.js";
|
|
9
9
|
import { type LiveActivityKind } from "./live.js";
|
|
10
|
-
import { type TokenPurpose } from "./api-tokens.js";
|
|
10
|
+
import { type CredentialKind, type TokenPurpose } from "./api-tokens.js";
|
|
11
11
|
import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
|
|
12
12
|
import { type ExecutionProfile, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
|
|
13
13
|
import { type ProviderRoom, type CandidateTier, type TaskSize, type TaskSizing, type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RouteOverride } from "./phase-routing.js";
|
|
@@ -152,13 +152,24 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
152
152
|
hash: string;
|
|
153
153
|
} | null;
|
|
154
154
|
};
|
|
155
|
-
export declare const SCHEMA_VERSION =
|
|
155
|
+
export declare const SCHEMA_VERSION = 118;
|
|
156
|
+
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
157
|
+
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
158
|
+
/**
|
|
159
|
+
* v118 (D7): one credential table for every machine credential. `id` is the canonical identity every reference holds;
|
|
160
|
+
* `public_id` is the id the secret presents (so_<id>_…, lt_<id>_…) or a coordinator's cid, unique per kind. They are
|
|
161
|
+
* equal unless the merge found two kinds sharing an id. `account` is the person a token acts as (a lead's owner; for
|
|
162
|
+
* a coordinator, who minted it); `projects_json` the project limit (a coordinator's projects); `generation` which
|
|
163
|
+
* issue it is (1, then one more per rotation, per lead token for the same owner, per coordinator of the same name).
|
|
164
|
+
* Lead tokens never expire; a coordinator made before v101 has no expiry.
|
|
165
|
+
*/
|
|
166
|
+
export declare const CREDENTIAL_TABLE = "\nCREATE TABLE IF NOT EXISTS credential (\n id TEXT PRIMARY KEY,\n kind TEXT NOT NULL CHECK (kind IN ('person','lead','coordinator')),\n public_id TEXT NOT NULL,\n account TEXT NOT NULL,\n name TEXT NOT NULL,\n secret_hash TEXT NOT NULL,\n access TEXT NOT NULL CHECK (access IN ('read','act')),\n projects_json TEXT,\n per_hour INTEGER,\n purpose TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp')),\n generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),\n created_at TEXT NOT NULL,\n created_by TEXT NOT NULL,\n expires_at TEXT,\n last_used_at TEXT,\n revoked_at TEXT,\n revoked_by TEXT,\n replaces TEXT,\n replaced_by TEXT,\n overlap_until TEXT,\n UNIQUE (kind, public_id)\n);\n";
|
|
156
167
|
/** v117: each table renamed, by its old name. Rows, ids, foreign keys and indexes go with it (renameForV117). */
|
|
157
168
|
export declare const V117_RENAMED_TABLES: Readonly<Record<string, string>>;
|
|
158
169
|
/** v117: the columns renamed with them (by the table's new name): a subagent's rows name it `subagent`. */
|
|
159
170
|
export declare const V117_RENAMED_COLUMNS: readonly (readonly [table: string, from: string, to: string])[];
|
|
160
|
-
/** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117
|
|
161
|
-
* same row after it, though RENAME now reports
|
|
171
|
+
/** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 (or
|
|
172
|
+
* v118) is the same row after it, though RENAME (or the credential merge) now reports what it points at by the new names. */
|
|
162
173
|
export declare function orphanKey(row: Record<string, unknown>): string;
|
|
163
174
|
/** v115: the tables migrateToV115 drops. */
|
|
164
175
|
export declare const V115_DROPPED_TABLES: readonly string[];
|
|
@@ -194,7 +205,9 @@ export declare const V115_DROPPED_TABLES: readonly string[];
|
|
|
194
205
|
* transaction that checks each count before the old tables are dropped; the
|
|
195
206
|
* rehearsal checks each moved table's rows arrived (HISTORY_RULES). v117 renames the mate's tables to the lead's and the
|
|
196
207
|
* teammates' to its subagents' (SQLite's RENAME, every row, id and link carried), renames their teammate column, and rewrites four internal kinds from 'teammate' to 'subagent' in place; the rehearsal checks
|
|
197
|
-
* each renamed table's rows arrived under its new name.
|
|
208
|
+
* each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
|
|
209
|
+
* coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
|
|
210
|
+
* before the old tables drop, and the references that named them name credential in place, their rows untouched.
|
|
198
211
|
*/
|
|
199
212
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
200
213
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
@@ -2358,7 +2371,16 @@ export declare class Store {
|
|
|
2358
2371
|
};
|
|
2359
2372
|
/** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
|
|
2360
2373
|
endRotatedApiTokens(now: Date): number;
|
|
2361
|
-
/**
|
|
2374
|
+
/**
|
|
2375
|
+
* D7: the one check every machine credential takes. The credential a presented secret is (its kind and id), when
|
|
2376
|
+
* its kept hash matches in constant time; null otherwise. Whether it is live, and what its kind may do, stays with
|
|
2377
|
+
* each caller's own policy (a person's API token, a lead token, a coordinator).
|
|
2378
|
+
*/
|
|
2379
|
+
presentedCredential(presented: string): {
|
|
2380
|
+
kind: CredentialKind;
|
|
2381
|
+
id: string;
|
|
2382
|
+
} | null;
|
|
2383
|
+
/** A person's API token by id with its kept hash (for the check), or null. */
|
|
2362
2384
|
apiTokenSecret(id: string): {
|
|
2363
2385
|
row: ApiTokenRow;
|
|
2364
2386
|
secretHash: string;
|