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.
Files changed (73) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/action-ledger.js +10 -5
  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/browser/workspace.js +1 -1
  11. package/dist/builder.js +4 -2
  12. package/dist/chat-channel.d.ts +1 -0
  13. package/dist/chat-channel.js +3 -0
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/cli-http.d.ts +7 -0
  16. package/dist/cli-http.js +30 -4
  17. package/dist/cli.js +2 -1
  18. package/dist/contract-changes.d.ts +52 -0
  19. package/dist/contract-changes.js +61 -0
  20. package/dist/coordinator.d.ts +2 -1
  21. package/dist/coordinator.js +22 -31
  22. package/dist/decision.d.ts +4 -0
  23. package/dist/decision.js +8 -0
  24. package/dist/deploy-lock.d.ts +8 -0
  25. package/dist/deploy-lock.js +45 -0
  26. package/dist/desktop-update.d.ts +1 -1
  27. package/dist/dispatch.js +1 -1
  28. package/dist/export.js +2 -2
  29. package/dist/lead-cli.js +3 -0
  30. package/dist/lead-doors.js +3 -0
  31. package/dist/lead-tools.js +2 -1
  32. package/dist/mcp-http.js +5 -5
  33. package/dist/needs-you.js +3 -1
  34. package/dist/operate.js +23 -11
  35. package/dist/password-bearer.d.ts +27 -0
  36. package/dist/password-bearer.js +59 -0
  37. package/dist/planner-source.d.ts +23 -53
  38. package/dist/planner-source.js +43 -68
  39. package/dist/release-gate.d.ts +2 -3
  40. package/dist/release-gate.js +44 -16
  41. package/dist/remote-command.js +0 -2
  42. package/dist/remote-exec.d.ts +19 -0
  43. package/dist/remote-exec.js +9 -8
  44. package/dist/request-budget.d.ts +1 -1
  45. package/dist/scope.js +16 -1
  46. package/dist/serve.js +15 -0
  47. package/dist/server/edge-refusal.d.ts +2 -0
  48. package/dist/server/edge-refusal.js +8 -6
  49. package/dist/server/guards.js +5 -4
  50. package/dist/server/http.d.ts +2 -0
  51. package/dist/server/remote.js +6 -13
  52. package/dist/server/render-chat.js +10 -0
  53. package/dist/server/render-tasks.js +19 -9
  54. package/dist/server/route-table.js +0 -3
  55. package/dist/server/tasks.js +3 -1
  56. package/dist/session-cli.d.ts +38 -9
  57. package/dist/session-cli.js +141 -154
  58. package/dist/session-contract.js +2 -2
  59. package/dist/session-remote.d.ts +19 -0
  60. package/dist/session-remote.js +86 -0
  61. package/dist/store.d.ts +72 -12
  62. package/dist/store.js +367 -105
  63. package/dist/task-status.js +3 -1
  64. package/dist/team-cli.d.ts +3 -0
  65. package/dist/team-cli.js +23 -3
  66. package/dist/telegram-lead.js +6 -0
  67. package/dist/telegram.js +9 -7
  68. package/dist/toolroll-update.js +10 -1
  69. package/package.json +1 -1
  70. package/dist/session-http.d.ts +0 -24
  71. package/dist/session-http.js +0 -170
  72. package/dist/session-server.d.ts +0 -17
  73. package/dist/session-server.js +0 -39
@@ -261,7 +261,9 @@ export const taskStatusOf = statusOf;
261
261
  * wait on a person stay Needs you; everything else waits in the queue. */
262
262
  const CODE_STAGE = {
263
263
  queued: ["queued"], ready: ["queued"], "worker-at-capacity": ["queued"], "retry-scheduled": ["queued"], updating: ["queued"],
264
- "waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"], "planner-source": ["queued"],
264
+ "waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"],
265
+ // A request the planner cannot be given (too long to hand back, say) waits on a person to shorten it.
266
+ "planner-source": ["needs-you", "fix-request"],
265
267
  running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"],
266
268
  "signed-out": ["needs-you", "sign-in"],
267
269
  "needs-approval": ["needs-you", "approval"],
@@ -14,6 +14,9 @@ export type TeamCliOptions = {
14
14
  export declare const TEAM_CLI_ACTIONS: readonly ['connect', 'lead list', 'lead create', 'lead update', 'lead member', 'lead transfer', 'conversation list', 'conversation create', 'conversation show', 'conversation member', 'conversation edit', 'conversation withdraw', 'conversation read', 'conversation follow', 'conversation stop'];
15
15
  export declare class UsageError extends Error {
16
16
  }
17
+ /** Credentials are never sent to a URL with embedded authority, an API path, query, fragment or an insecure remote
18
+ * host. Fetch cannot follow redirects. */
19
+ export declare function serviceOrigin(raw: string): string;
17
20
  type Profile = {
18
21
  origin: string;
19
22
  account: string;
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);
@@ -204,6 +204,12 @@ export function applyProposalTap(store, binding, token, message, repos, options,
204
204
  const who = verified.who;
205
205
  const preview = proposalPreview(store, proposal, actionRepos, "telegram");
206
206
  const origin = options.phoneOrigin?.() ?? null;
207
+ if (preview.closed) {
208
+ store.consumeTelegramProposalActions(proposal.id, now);
209
+ ack("This question is closed.");
210
+ edit(preview.text, []);
211
+ return { effects, confirmed: false, ignored: true };
212
+ }
207
213
  if (action.phase === "dismiss") {
208
214
  if (!store.consumeTelegramProposalAction(token, now)) {
209
215
  ack("that button was already used");
package/dist/telegram.js CHANGED
@@ -25,7 +25,7 @@ import { resultImageFileName, resultTaskLabel, verifyResultImage } from "./chat-
25
25
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
26
26
  import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
27
  import { join } from "node:path";
28
- import { validateNote } from "./decision.js";
28
+ import { closedQuestionWords, validateNote } from "./decision.js";
29
29
  import { isShotsKind, resultShotsFor } from "./result-shots.js";
30
30
  import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
@@ -1998,7 +1998,7 @@ function applyNote(context, update, effects) {
1998
1998
  return;
1999
1999
  }
2000
2000
  if (decision.state === "answered") {
2001
- say(`already answered: ${decision.choice ?? "?"} — this note did not travel`);
2001
+ say(`${decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : `already answered: ${decision.choice ?? "?"}`} — this note did not travel`);
2002
2002
  report.ignored++;
2003
2003
  return;
2004
2004
  }
@@ -2344,8 +2344,8 @@ function applyCallback(context, update, effects) {
2344
2344
  }
2345
2345
  if (decision.state === "answered") {
2346
2346
  store.consumeTelegramAction(token, clock());
2347
- ack(`already answered: ${decision.choice ?? "?"}`);
2348
- editText(answeredText(store, decision));
2347
+ ack(decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : `already answered: ${decision.choice ?? "?"}`);
2348
+ editText(answeredText(store, decision), []);
2349
2349
  return;
2350
2350
  }
2351
2351
  if (action.phase === "choose") {
@@ -2451,13 +2451,13 @@ function answerNow(context, decision, choice, binding, ack, editText) {
2451
2451
  editText(answeredText(store, answered.decision));
2452
2452
  return;
2453
2453
  }
2454
- if (answered.reason === "already-answered") {
2454
+ if (answered.reason === "already-answered" || answered.reason === "closed") {
2455
2455
  if (draft !== null)
2456
2456
  store.setNoteDraftState(draft.id, "discarded");
2457
2457
  const settled = store.getDecision(decision.id);
2458
- ack(`already answered: ${settled?.choice ?? "?"}${draft === null ? "" : " — your note did NOT travel"}`);
2458
+ ack(`${settled?.supersededReason != null ? closedQuestionWords(settled.supersededReason) : `already answered: ${settled?.choice ?? "?"}`}${draft === null ? "" : " — your note did NOT travel"}`);
2459
2459
  if (settled !== null)
2460
- editText(answeredText(store, settled));
2460
+ editText(answeredText(store, settled), []);
2461
2461
  return;
2462
2462
  }
2463
2463
  ack(`could not answer: ${answered.reason}`);
@@ -2479,6 +2479,8 @@ function armedDecisionText(option, note) {
2479
2479
  ].join("\n");
2480
2480
  }
2481
2481
  function answeredText(store, decision) {
2482
+ if (decision.supersededReason !== null)
2483
+ return [closedQuestionWords(decision.supersededReason), taskOf(store, decision)].join("\n");
2482
2484
  const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice ?? "?";
2483
2485
  return [
2484
2486
  `✓ Answered: ${chosen}`,
@@ -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, V119_ADDED_COLUMNS } 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,11 @@ 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}'` } }])),
387
+ // v119: planning generations and a question's closure reason are new columns; every row saved before them reads
388
+ // the declared value (generation 0, no closure reason).
389
+ ...Object.fromEntries(Object.entries(V119_ADDED_COLUMNS).map(([table, columns]) => [table, { added: Object.fromEntries(Object.entries(columns).map(([column, { saved }]) => [column, saved])) }])),
385
390
  };
386
391
  /** The tables a migration may remove whole (v115's removed features). */
387
392
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -535,6 +540,10 @@ export function changedHistory(db, before) {
535
540
  if (t.columns.some(c => !kept(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
536
541
  return true;
537
542
  const columns = t.columns.filter(c => present.has(c));
543
+ // A declared new column: every row saved before it reads its declared value.
544
+ const added = Object.entries(rule?.added ?? {}).filter(([c]) => !t.columns.includes(c) && present.has(c));
545
+ if (t.rowid && added.some(([c, saved]) => Number(db.prepare("SELECT count(*) AS n FROM " + quote(t.name) + " WHERE rowid <= ? AND " + quote(c) + " IS NOT " + saved).get(t.last)["n"]) > 0))
546
+ return true;
538
547
  if (!t.rowid) {
539
548
  const after = rowsHash(db, t.name, columns);
540
549
  return after.count !== t.count || after.hash !== t.hash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.57",
3
+ "version": "0.9.59",
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
- }