toolroll 0.9.57 → 0.9.59
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 +15 -0
- package/dist/action-ledger.js +10 -5
- 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/browser/workspace.js +1 -1
- package/dist/builder.js +4 -2
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +3 -0
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/export.js +2 -2
- package/dist/lead-cli.js +3 -0
- package/dist/lead-doors.js +3 -0
- package/dist/lead-tools.js +2 -1
- package/dist/mcp-http.js +5 -5
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +23 -11
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- 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 +16 -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/render-chat.js +10 -0
- package/dist/server/render-tasks.js +19 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +3 -1
- 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 +72 -12
- package/dist/store.js +367 -105
- package/dist/task-status.js +3 -1
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +10 -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
|
+
}
|