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.
Files changed (47) hide show
  1. package/CHANGELOG.md +8 -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-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/cli-http.d.ts +7 -0
  11. package/dist/cli-http.js +30 -4
  12. package/dist/cli.js +2 -1
  13. package/dist/coordinator.d.ts +2 -1
  14. package/dist/coordinator.js +22 -31
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/export.js +2 -2
  17. package/dist/mcp-http.js +5 -5
  18. package/dist/operate.js +2 -1
  19. package/dist/password-bearer.d.ts +27 -0
  20. package/dist/password-bearer.js +59 -0
  21. package/dist/remote-command.js +0 -2
  22. package/dist/remote-exec.d.ts +19 -0
  23. package/dist/remote-exec.js +9 -8
  24. package/dist/request-budget.d.ts +1 -1
  25. package/dist/scope.js +2 -1
  26. package/dist/serve.js +15 -0
  27. package/dist/server/edge-refusal.d.ts +2 -0
  28. package/dist/server/edge-refusal.js +8 -6
  29. package/dist/server/guards.js +5 -4
  30. package/dist/server/http.d.ts +2 -0
  31. package/dist/server/remote.js +6 -13
  32. package/dist/server/route-table.js +0 -3
  33. package/dist/session-cli.d.ts +38 -9
  34. package/dist/session-cli.js +141 -154
  35. package/dist/session-contract.js +2 -2
  36. package/dist/session-remote.d.ts +19 -0
  37. package/dist/session-remote.js +86 -0
  38. package/dist/store.d.ts +28 -6
  39. package/dist/store.js +259 -93
  40. package/dist/team-cli.d.ts +3 -0
  41. package/dist/team-cli.js +23 -3
  42. package/dist/toolroll-update.js +3 -1
  43. package/package.json +1 -1
  44. package/dist/session-http.d.ts +0 -24
  45. package/dist/session-http.js +0 -170
  46. package/dist/session-server.d.ts +0 -17
  47. package/dist/session-server.js +0 -39
package/dist/team-cli.js CHANGED
@@ -5,11 +5,11 @@ import { randomBytes } from 'node:crypto';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
7
7
  import { envelopeJson } from './envelope.js';
8
- import { sessionServiceOrigin } from './session-cli.js';
9
8
  import { databasePath } from './store.js';
10
9
  import { configBase, namedPath } from './names.js';
11
10
  import { parseApiToken } from './api-tokens.js';
12
11
  import { deprecationWarning } from './deprecations.js';
12
+ import { PASSWORD_PROFILE_WARNING } from './password-bearer.js';
13
13
  /** The central service's own lead verbs; every other `lead …` (token, say, or none) is the local lead's. */
14
14
  const CENTRAL_LEAD_ACTIONS = ['list', 'create', 'update', 'member', 'transfer'];
15
15
  /** D5: deprecated (deprecations.ts). Each still runs this release, hidden from help and the contract, with one warning on stderr. */
@@ -45,6 +45,22 @@ existing local commands. Credentials stay in a private file, never in URLs or ar
45
45
  Reads and attachment do not start model work. Server permissions remain authoritative.`;
46
46
  export class UsageError extends Error {
47
47
  }
48
+ /** Credentials are never sent to a URL with embedded authority, an API path, query, fragment or an insecure remote
49
+ * host. Fetch cannot follow redirects. */
50
+ export function serviceOrigin(raw) {
51
+ let url;
52
+ try {
53
+ url = new URL(raw);
54
+ }
55
+ catch {
56
+ throw new UsageError('The service origin must be HTTPS or loopback HTTP.');
57
+ }
58
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
59
+ throw new UsageError('The service origin must contain only the origin, with no credentials, path, query or fragment.');
60
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)))
61
+ throw new UsageError('The service origin requires HTTPS except for loopback HTTP.');
62
+ return url.origin;
63
+ }
48
64
  /** Transport metadata stays local to the CLI; only an actual HTTP 429 permits a follower read retry. */
49
65
  const RETRY_AFTER_MS = Symbol('retry-after-ms');
50
66
  function retryDelay(header) {
@@ -104,7 +120,7 @@ function profiles(options) {
104
120
  if (!record(parsed) || parsed.version !== 1 || typeof parsed.active !== 'string' || !record(parsed.profiles))
105
121
  throw new Error();
106
122
  for (const [name, one] of Object.entries(parsed.profiles)) {
107
- if (!/^[A-Za-z0-9_-]{1,64}$/.test(name) || !record(one) || typeof one.origin !== 'string' || sessionServiceOrigin(one.origin) !== one.origin || typeof one.account !== 'string' || !accountValid(one.account) || typeof one.token !== 'string' || !tokenValid(one.token))
123
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name) || !record(one) || typeof one.origin !== 'string' || serviceOrigin(one.origin) !== one.origin || typeof one.account !== 'string' || !accountValid(one.account) || typeof one.token !== 'string' || !tokenValid(one.token))
108
124
  throw new Error();
109
125
  }
110
126
  if (!Object.hasOwn(parsed.profiles, parsed.active))
@@ -387,7 +403,7 @@ export async function maybeRunTeamCommand(argv, write, options = {}) {
387
403
  if (command === 'connect') {
388
404
  let origin;
389
405
  try {
390
- origin = sessionServiceOrigin(parsed.positions[0]);
406
+ origin = serviceOrigin(parsed.positions[0]);
391
407
  }
392
408
  catch {
393
409
  throw new UsageError('Use an HTTPS service origin (loopback HTTP is allowed), without credentials, paths, query or fragment.');
@@ -408,6 +424,8 @@ export async function maybeRunTeamCommand(argv, write, options = {}) {
408
424
  secret = (flags['token-file'] ? privateFile(value(flags, 'token-file'), 8192) : await (options.readStdin ?? stdinSecret)()).trim();
409
425
  if (!accountValid(account) || !tokenValid(secret))
410
426
  throw new UsageError('Provide an account name and a valid sign-in secret from your selected source.');
427
+ if (parseApiToken(secret) === null)
428
+ stderr(PASSWORD_PROFILE_WARNING);
411
429
  const profile = { origin, account, token: secret }, checked = await perform(profile, { operation: 'list', args: {} }, options);
412
430
  if (!checked.ok) {
413
431
  emit(checked);
@@ -421,6 +439,8 @@ export async function maybeRunTeamCommand(argv, write, options = {}) {
421
439
  if (!profile || !Object.hasOwn(saved.profiles, profileName))
422
440
  throw new UsageError('No saved connection. Use connect with your individual sign-in first.');
423
441
  secret = profile.token;
442
+ if (parseApiToken(secret) === null)
443
+ stderr(PASSWORD_PROFILE_WARNING);
424
444
  const call = (operation, args, signal) => perform(profile, { operation, args }, { ...options, ...(signal === undefined ? {} : { signal }) });
425
445
  if (command === 'chat' || command === 'brief')
426
446
  return await chat(command, flags, profile.account, call, emit, options, stderr);
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
35
35
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
37
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES } from "./store.js";
38
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES } from "./store.js";
39
39
  import { isNewer, REGISTRY } from "./releases.js";
40
40
  import { markNeverIndex } from "./never-index.js";
41
41
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -382,6 +382,8 @@ const HISTORY_RULES = {
382
382
  // subagent*); a column renamed in a table that keeps its name keeps its values under the new one.
383
383
  ...Object.fromEntries(Object.entries(V117_RENAMED_TABLES).map(([old, now]) => [old, { moved: { into: now, where: "1" } }])),
384
384
  ...Object.fromEntries(V117_RENAMED_COLUMNS.filter(([table]) => !Object.values(V117_RENAMED_TABLES).includes(table)).map(([table, from, to]) => [table, { renamed: { [from]: to } }])),
385
+ // v118 (D7): every API token, coordinator and lead token arrives in the one credential table under its kind.
386
+ ...Object.fromEntries(Object.entries(V118_MERGED_TABLES).map(([table, kind]) => [table, { moved: { into: "credential", where: `kind = '${kind}'` } }])),
385
387
  };
386
388
  /** The tables a migration may remove whole (v115's removed features). */
387
389
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.57",
3
+ "version": "0.9.58",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"
@@ -1,24 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { type SessionOperation, type SessionRequests, type SessionResponse } from './session-contract.js';
3
- import { type Admission } from './request-budget.js';
4
- export type SessionHttpActor = {
5
- name: string;
6
- generation: number;
7
- };
8
- export type SessionHttpExecute = <O extends SessionOperation>(actor: SessionHttpActor, operation: O, request: SessionRequests[O]) => Promise<SessionResponse>;
9
- export type SessionHttpOptions = {
10
- /** Verify the bearer against the current account and installation-wide
11
- * operator role. Derive both fields here, never from the JSON request.
12
- * Host, TLS and other server admission checks remain with the caller. */
13
- authenticate: (request: IncomingMessage) => SessionHttpActor | null | Promise<SessionHttpActor | null>;
14
- /** The existing live owner's operation. Null means no owner is ready.
15
- * The service rechecks actor generation and project access at execution. */
16
- execute: SessionHttpExecute | null;
17
- /** The bearer request's budget by source (request-budget.ts): charged once, before the credential is checked or a body read. */
18
- admit?: (request: IncomingMessage) => Admission;
19
- /** Account admission after successful authentication, before any body read or owner call. */
20
- admitAuthenticated?: (request: IncomingMessage, actor: SessionHttpActor) => Admission;
21
- };
22
- /** Handle this API family before the browser's form parser or login redirects.
23
- * This adapter dispatches at most once. It owns no catalog, database or provider. */
24
- export declare function handleSessionHttp(request: IncomingMessage, response: ServerResponse, options: SessionHttpOptions): Promise<boolean>;
@@ -1,170 +0,0 @@
1
- import { adapterPolicy } from "./server/route-policy.js";
2
- import { SESSION_REQUEST_BYTES, SESSION_RESPONSE_BYTES, isSessionResponse, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
3
- import { limitWords } from './request-budget.js';
4
- function readBody(request) {
5
- return new Promise(resolve => {
6
- let size = 0;
7
- let chunks = [];
8
- let settled = false;
9
- const finish = (result) => {
10
- if (settled)
11
- return;
12
- settled = true;
13
- chunks = [];
14
- resolve(result);
15
- };
16
- const cleanup = () => {
17
- request.off('data', onData);
18
- request.off('end', onEnd);
19
- request.off('aborted', onAborted);
20
- request.off('error', onError);
21
- request.off('close', onClose);
22
- };
23
- const onData = (chunk) => {
24
- // After rejecting an oversized request, drain without retaining it.
25
- if (settled)
26
- return;
27
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28
- size += bytes.length;
29
- if (size > SESSION_REQUEST_BYTES)
30
- finish({ ok: false, status: 413, reason: 'request-too-large', message: 'The session request is too large.' });
31
- else
32
- chunks.push(bytes);
33
- };
34
- const onEnd = () => {
35
- cleanup();
36
- if (settled)
37
- return;
38
- try {
39
- const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
40
- finish({ ok: true, value });
41
- }
42
- catch {
43
- finish({ ok: false, status: 400, reason: 'invalid-json', message: 'Send a valid UTF-8 JSON request.' });
44
- }
45
- };
46
- const onAborted = () => {
47
- finish({ ok: false, status: 400, reason: 'incomplete-request', message: 'The session request did not finish arriving.' });
48
- };
49
- const onError = () => { onAborted(); cleanup(); };
50
- const onClose = () => { onAborted(); cleanup(); };
51
- request.on('data', onData);
52
- request.once('end', onEnd);
53
- request.once('aborted', onAborted);
54
- request.once('error', onError);
55
- request.once('close', onClose);
56
- if (request.aborted)
57
- onAborted();
58
- });
59
- }
60
- /** Handle this API family before the browser's form parser or login redirects.
61
- * This adapter dispatches at most once. It owns no catalog, database or provider. */
62
- export async function handleSessionHttp(request, response, options) {
63
- const path = (request.url ?? '').split('?')[0] ?? '';
64
- if (path !== '/api/sessions' && !path.startsWith('/api/sessions/'))
65
- return false;
66
- const match = /^\/api\/sessions\/([a-z]+)$/.exec(request.url ?? '');
67
- const spec = match?.[1] === undefined ? undefined : sessionDescriptor(match[1]);
68
- const operation = spec?.operation;
69
- const write = (status, body, notSent = false) => {
70
- if (response.destroyed || response.writableEnded)
71
- return;
72
- response.statusCode = status;
73
- response.setHeader('content-type', 'application/json; charset=utf-8');
74
- response.setHeader('content-length', Buffer.byteLength(body));
75
- response.setHeader('cache-control', 'no-store');
76
- response.setHeader('x-content-type-options', 'nosniff');
77
- if (notSent)
78
- response.setHeader('x-standing-orders-session-delivery', 'not-sent');
79
- else
80
- response.removeHeader('x-standing-orders-session-delivery');
81
- response.end(body);
82
- };
83
- const reject = (status, reason, message) => {
84
- // No owner call has occurred. This marker is never inferred from an
85
- // exception, a lost response, or a response too large to deliver.
86
- const reply = { version: 1, ...(operation === undefined ? {} : { operation }), ok: false,
87
- status: 'rejected', delivery: 'not-sent', retry: spec?.mutation === false ? 'safe-read' : 'never', reason, message, nextActions: [] };
88
- write(status, JSON.stringify(reply), true);
89
- request.resume();
90
- return true;
91
- };
92
- if (!spec || operation === undefined)
93
- return reject(404, 'unsupported-operation', 'This session operation is not supported.');
94
- if (request.method !== 'POST') {
95
- response.setHeader('allow', 'POST');
96
- return reject(405, 'method-not-allowed', 'Session operations require POST.');
97
- }
98
- if (request.headers.origin !== undefined)
99
- return reject(403, 'browser-request', 'Use the authenticated session client for this operation.');
100
- const authorizationCount = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index]?.toLowerCase() === 'authorization').length;
101
- if (authorizationCount !== 1 || !/^Bearer (.+):(.+)$/.test(request.headers.authorization ?? '')) {
102
- return reject(401, 'unauthenticated', 'Current operator bearer credentials are required.');
103
- }
104
- const refuseAdmission = (admitted) => {
105
- if (admitted.ok)
106
- return false;
107
- if (admitted.status === 503)
108
- return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
109
- response.setHeader('retry-after', String(admitted.retryAfter));
110
- return reject(429, 'rate-limited', limitWords(admitted.limit, admitted.retryAfter));
111
- };
112
- if (options.admit && refuseAdmission(options.admit(request)))
113
- return true;
114
- let actor;
115
- try {
116
- actor = await options.authenticate(request);
117
- }
118
- catch {
119
- return reject(503, 'authentication-unavailable', 'Operator access could not be checked. Try again after the service is ready.');
120
- }
121
- if (actor === null || typeof actor.name !== 'string' || !actor.name.trim() || !Number.isSafeInteger(actor.generation) || actor.generation < 1) {
122
- return reject(401, 'unauthenticated', 'Current operator bearer credentials are required.');
123
- }
124
- if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
125
- return true;
126
- if (!adapterPolicy({ caller: "bearer", capability: "act" }, spec.mutation ? "act" : "read").ok)
127
- return reject(403, "forbidden", "Current operator access is required.");
128
- const media = request.headers['content-type']?.split(';')[0]?.trim().toLowerCase();
129
- if (media !== 'application/json')
130
- return reject(415, 'unsupported-media-type', 'Send the session request as application/json.');
131
- const length = request.headers['content-length'];
132
- if (length !== undefined && Number(length) > SESSION_REQUEST_BYTES)
133
- return reject(413, 'request-too-large', 'The session request is too large.');
134
- if (options.execute === null)
135
- return reject(503, 'session-unavailable', 'The session owner is not ready.');
136
- const body = await readBody(request);
137
- if (!body.ok)
138
- return reject(body.status, body.reason, body.message);
139
- const validated = validateSessionRequest(operation, body.value);
140
- if (!validated.ok)
141
- return reject(400, 'invalid-request', validated.message);
142
- if (request.aborted || response.destroyed)
143
- return true;
144
- const uncertain = (reason, message) => {
145
- const sessionId = 'sessionId' in validated.request ? validated.request.sessionId : undefined;
146
- return { version: 1, operation, ok: false, status: 'uncertain', delivery: 'unknown', retry: 'inspect-first', reason, message,
147
- nextActions: [{ operation: sessionId ? 'show' : 'list', label: sessionId ? 'Inspect session' : 'Inspect saved sessions', ...(sessionId ? { sessionId } : {}) }] };
148
- };
149
- let reply;
150
- let encoded;
151
- let status;
152
- try {
153
- reply = await options.execute(actor, operation, validated.request);
154
- if (!isSessionResponse(reply, operation))
155
- throw new Error('Invalid owner response');
156
- encoded = JSON.stringify(reply);
157
- if (Buffer.byteLength(encoded) > SESSION_RESPONSE_BYTES) {
158
- reply = uncertain('response-too-large', 'The owner response is too large to deliver. Inspect saved activity before continuing; this request was not retried.');
159
- encoded = JSON.stringify(reply);
160
- }
161
- status = reply.status === 'rejected' ? 409 : reply.status === 'uncertain' ? 502 : reply.status === 'pending' ? 202 : 200;
162
- }
163
- catch {
164
- reply = uncertain('unconfirmed-response', 'The owner response could not be confirmed. Inspect saved activity before continuing; this request was not retried.');
165
- encoded = JSON.stringify(reply);
166
- status = 502;
167
- }
168
- write(status, encoded);
169
- return true;
170
- }
@@ -1,17 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type { Store } from './store.js';
3
- import type { CodingWorkspace } from './coding-workspace.js';
4
- import { type SessionHttpActor } from './session-http.js';
5
- import type { Admission } from './request-budget.js';
6
- /** Bind the transport to createDecisionServer's existing catalog owner and
7
- * live project admission. No cookie, coordinator token or caller-supplied actor
8
- * can acquire installation-wide native coding authority through this adapter. */
9
- export declare function createSessionEndpoint(options: {
10
- store: Store;
11
- workspace: CodingWorkspace | null;
12
- projects: () => readonly string[];
13
- projectAllowed: (repo: string) => boolean;
14
- /** The request's source budget (request-budget.ts), charged once before the password is checked. */
15
- admit?: (request: IncomingMessage) => Admission;
16
- admitAuthenticated?: (request: IncomingMessage, actor: SessionHttpActor) => Admission;
17
- }): (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
@@ -1,39 +0,0 @@
1
- import { realpathSync } from 'node:fs';
2
- import { authenticateAccount } from './scope.js';
3
- import { handleSessionHttp } from './session-http.js';
4
- import { SessionService } from './session-service.js';
5
- /** Bind the transport to createDecisionServer's existing catalog owner and
6
- * live project admission. No cookie, coordinator token or caller-supplied actor
7
- * can acquire installation-wide native coding authority through this adapter. */
8
- export function createSessionEndpoint(options) {
9
- const { store } = options;
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 (request, response) => handleSessionHttp(request, response, {
24
- ...(options.admit === undefined ? {} : { admit: options.admit }),
25
- ...(options.admitAuthenticated === undefined ? {} : { admitAuthenticated: options.admitAuthenticated }),
26
- authenticate: request => {
27
- const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? '');
28
- if (!bearer)
29
- return null;
30
- const name = bearer[1];
31
- const authentication = authenticateAccount(store, name, bearer[2]);
32
- if (!authentication.ok)
33
- return null;
34
- const actor = { name, generation: authentication.generation };
35
- return authorized(actor) ? actor : null;
36
- },
37
- execute: service === null ? null : (actor, operation, request) => service.execute(actor, operation, request),
38
- });
39
- }