toolroll 0.9.56 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/action-ledger.js +0 -4
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-adapters.d.ts +5 -0
  8. package/dist/assignment-delivery.js +1 -1
  9. package/dist/assignment-status.js +1 -1
  10. package/dist/assignment.d.ts +24 -3
  11. package/dist/assignment.js +39 -12
  12. package/dist/browser-workspace.js +1 -1
  13. package/dist/chat-delivery.js +3 -3
  14. package/dist/chat-voice.js +1 -1
  15. package/dist/check-levels.d.ts +3 -0
  16. package/dist/check-levels.js +5 -0
  17. package/dist/cli-http.d.ts +7 -0
  18. package/dist/cli-http.js +30 -4
  19. package/dist/cli.js +2 -1
  20. package/dist/coordinator.d.ts +2 -1
  21. package/dist/coordinator.js +22 -31
  22. package/dist/desktop-update.d.ts +1 -1
  23. package/dist/dispose.js +0 -5
  24. package/dist/export.js +2 -2
  25. package/dist/lead-status.d.ts +1 -1
  26. package/dist/lead-status.js +2 -2
  27. package/dist/mcp-http.js +5 -5
  28. package/dist/operate.js +19 -9
  29. package/dist/password-bearer.d.ts +27 -0
  30. package/dist/password-bearer.js +59 -0
  31. package/dist/proof.d.ts +9 -0
  32. package/dist/proof.js +12 -0
  33. package/dist/release-coverage.d.ts +16 -0
  34. package/dist/release-coverage.js +92 -0
  35. package/dist/remote-command.js +0 -2
  36. package/dist/remote-exec.d.ts +19 -0
  37. package/dist/remote-exec.js +9 -8
  38. package/dist/request-budget.d.ts +1 -1
  39. package/dist/scope.js +2 -1
  40. package/dist/serve.js +15 -0
  41. package/dist/server/chat.js +8 -6
  42. package/dist/server/edge-refusal.d.ts +2 -0
  43. package/dist/server/edge-refusal.js +8 -6
  44. package/dist/server/guards.js +5 -4
  45. package/dist/server/http.d.ts +2 -0
  46. package/dist/server/remote.js +6 -13
  47. package/dist/server/render-results.d.ts +4 -1
  48. package/dist/server/render-results.js +23 -9
  49. package/dist/server/route-table.js +0 -3
  50. package/dist/server/tasks.js +28 -25
  51. package/dist/session-cli.d.ts +38 -9
  52. package/dist/session-cli.js +141 -154
  53. package/dist/session-contract.js +2 -2
  54. package/dist/session-remote.d.ts +19 -0
  55. package/dist/session-remote.js +86 -0
  56. package/dist/store.d.ts +31 -11
  57. package/dist/store.js +276 -95
  58. package/dist/task-outcome-cli.js +3 -1
  59. package/dist/task-status.d.ts +59 -7
  60. package/dist/task-status.js +132 -16
  61. package/dist/team-cli.d.ts +3 -0
  62. package/dist/team-cli.js +23 -3
  63. package/dist/team-runtime.js +1 -1
  64. package/dist/telegram-status.d.ts +11 -4
  65. package/dist/telegram-status.js +17 -10
  66. package/dist/telegram.js +4 -4
  67. package/dist/toolroll-update.js +3 -1
  68. package/dist/work-index.d.ts +6 -1
  69. package/dist/work-index.js +50 -8
  70. package/dist/workspace-ui.js +2 -1
  71. package/package.json +1 -1
  72. package/dist/session-http.d.ts +0 -24
  73. package/dist/session-http.js +0 -170
  74. package/dist/session-server.d.ts +0 -17
  75. package/dist/session-server.js +0 -39
@@ -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, SESSION_RESPONSE_BYTES, isSessionResponse, sessionCapabilities, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
4
- const commonFlags = { url: 'value', as: 'value', 'token-env': 'value', 'token-file': 'value', json: 'flag', help: 'flag' };
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
- export function sessionCliFlags(spec) {
7
- return { ...commonFlags, ...Object.fromEntries(Object.keys(spec.flags).map(name => [name, 'value'])), ...(spec.operation === 'start' || spec.operation === 'send' ? { file: 'value', stdin: 'flag' } : {}) };
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. Session controls require operator credentials. Chat is the separate coordinator conversation.`;
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\nRequired: --url <service-origin> --as <operator>\n --token-env <variable-name> or --token-file <path>\n${required.map(name => ` --${name} <value>`).join('\n')}${'prompt' in spec.inputSchema.properties ? '\n --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(' ')}\nCredentials are sent only to the named HTTPS service or loopback HTTP. Redirects and automatic mutation retries are disabled.`;
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
- class UsageError extends Error {
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
- async function perform(parsed, options) {
156
- const { spec, request, url, account, token } = parsed;
157
- try {
158
- const response = await (options.fetch ?? fetch)(`${url}/api/sessions/${spec.operation}`, {
159
- method: 'POST', redirect: 'manual', credentials: 'omit', signal: AbortSignal.timeout(60_000),
160
- headers: { authorization: `Bearer ${account}:${token}`, 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request),
161
- });
162
- if (response.status >= 300 && response.status < 400) {
163
- await response.body?.cancel();
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 payload;
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
- function render(response) {
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 spec = sessionDescriptor(operation);
240
- if (!spec)
241
- return fail(`Unknown session operation. Use: ${SESSION_CLI_ACTIONS.join(', ')}.`);
242
- const allowed = sessionCliFlags(spec);
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 parsed;
243
+ let request;
244
+ let profile;
270
245
  try {
271
- parsed = await parseRequest(spec, flags, positionals, options);
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 credential source.');
259
+ return fail(error instanceof UsageError ? error.message : 'The session request could not be prepared. Check the prompt and saved connection.');
275
260
  }
276
- const response = await perform(parsed, options);
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 parsed.request ? { key: parsed.request.key } : {}) }));
265
+ write(envelopeJson({ ...response, command, ...('key' in request ? { key: request.key } : {}) }));
279
266
  else if (response.ok)
280
- write(render(response));
267
+ write(renderSessionResponse(response));
281
268
  else
282
- stderr(render(response));
283
- return response.ok ? 0 : response.status === 'uncertain' || response.reason === 'service-unavailable' ? 1 : 3;
269
+ stderr(renderSessionResponse(response));
270
+ return sessionExitCode(response);
284
271
  }
@@ -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/sessions/<operation>', authentication: 'operator account: Authorization Bearer name:token',
118
- authority: 'Operator credentials are required for session reads and controls. External coordinator credentials have no session access.',
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 = 117;
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 is the
161
- * same row after it, though RENAME now reports it (and what it points at) under the new names. */
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
- /** A token by id with its kept hash (for the check), or null. */
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;
@@ -3018,7 +3040,7 @@ export declare class Store {
3018
3040
  * transaction can never commit half a seal). A brief FILE whose seal
3019
3041
  * failed is an orphan on disk, not authority — no artifact row points at
3020
3042
  * it. Two concurrent seals race on the comment consumption count, the
3021
- * deterministic child id, or the repair chain's `source_run UNIQUE`, and
3043
+ * family's next version id, or the repair chain's `source_run UNIQUE`, and
3022
3044
  * exactly one wins; the loser refuses in words with zero rows.
3023
3045
  *
3024
3046
  * The child is filed UNAPPROVED, always: this method never stamps an
@@ -3044,10 +3066,9 @@ export declare class Store {
3044
3066
  bytes: number;
3045
3067
  capture: string;
3046
3068
  };
3047
- /** The child's own words: an optional deterministic id, its title,
3048
- * and the explicitly described repair appended to the SOURCE goal. */
3069
+ /** The child's own words: its title and the explicitly described repair
3070
+ * appended to the SOURCE goal. Its id is the family's next version. */
3049
3071
  child: {
3050
- id?: string;
3051
3072
  title: string;
3052
3073
  repair: string;
3053
3074
  };
@@ -3169,7 +3190,6 @@ export declare class Store {
3169
3190
  capture: string;
3170
3191
  };
3171
3192
  child: {
3172
- id: string;
3173
3193
  title: string;
3174
3194
  repair: string;
3175
3195
  };