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
@@ -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));
@@ -1,5 +1,5 @@
1
1
  import { type Store, type TaskState } from './store.js';
2
- import type { AssignmentSnapshot } from './assignment.js';
2
+ import { type AssignmentSnapshot } from './assignment.js';
3
3
  import type { WorkAction, WorkSummaryAccess } from './work-summary.js';
4
4
  import type { WorkStatus, WorkView } from './workspace-ui.js';
5
5
  import { type Ask, type AskChip } from './needs-you.js';
@@ -37,6 +37,7 @@ export type WorkIndexItem = {
37
37
  /** The viewer's own lead took it on (lead-voice.ts): "<name> is on it.", by the name they gave it, read now. */
38
38
  lead?: string;
39
39
  };
40
+ /** `root`: the evidence root, so a result whose checks were Off can read the release check that covers it. */
40
41
  export type WorkIndexOptions = {
41
42
  view?: WorkView;
42
43
  limit?: number;
@@ -44,6 +45,7 @@ export type WorkIndexOptions = {
44
45
  project?: string | null;
45
46
  state?: TaskState;
46
47
  leadId?: string;
48
+ root?: string;
47
49
  };
48
50
  /** How the list is grouped: the Needs you rows by their ask, then building, then the rest. */
49
51
  export type WorkIndexGroup = Ask | 'building' | 'rest';
@@ -78,6 +80,9 @@ export declare class WorkIndexCursorError extends Error {
78
80
  * Project counters share the page's admitted project/state filter.
79
81
  * Cursors bind the admitted scope, project and view, never broaden access. */
80
82
  export declare function workIndexPage(store: Store, now: Date, access: WorkSummaryAccess, options?: WorkIndexOptions): WorkIndexPage;
83
+ /** An exact family lookup through the list's saved-fact projection. It remains
84
+ * permission-bound and bypasses pagination, never action-time verification. */
85
+ export declare function workIndexTask(store: Store, taskId: string, now: Date, access: WorkSummaryAccess, root?: string): WorkIndexItem | null;
81
86
  export declare function workCountsByProject(store: Store, now: Date, access: WorkSummaryAccess): WorkProjectCounts[];
82
87
  /** Families whose headline is Ready for review, newest first: the Tasks
83
88
  * list's own rows, so a completed family (root or any revision), a failed
@@ -8,8 +8,12 @@ import { join } from 'node:path';
8
8
  import { readProjectAccess } from './project-access.js';
9
9
  import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
10
10
  import { scopeTermsProblem, stopFactOf } from './store.js';
11
- import { leadOnIt, plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
12
- import { isCheckLevel } from './check-levels.js';
11
+ import { assignmentChecksForRun } from './assignment.js';
12
+ import { checkBackingOf, completionBlockersOf, leadOnIt, plainReasonOf, replacedWords, requirementsOf, stageOfCode, taskStatusOf, unverifiedWhenRefuted, workToneOf } from './task-status.js';
13
+ import { releaseCoverageOf } from './release-coverage.js';
14
+ import { buildReviewOf } from './review-switch.js';
15
+ import { shownVerdictOf } from './proof.js';
16
+ import { isCheckLevel, requiredCheckCommandFor } from './check-levels.js';
13
17
  import { MARKER } from './worktree.js';
14
18
  import { withFollowUps } from './result-follow-ups.js';
15
19
  import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
@@ -359,6 +363,14 @@ function readCursor(value, scope) {
359
363
  * Project counters share the page's admitted project/state filter.
360
364
  * Cursors bind the admitted scope, project and view, never broaden access. */
361
365
  export function workIndexPage(store, now, access, options = {}) {
366
+ return readWorkIndexPage(store, now, access, options);
367
+ }
368
+ /** An exact family lookup through the list's saved-fact projection. It remains
369
+ * permission-bound and bypasses pagination, never action-time verification. */
370
+ export function workIndexTask(store, taskId, now, access, root) {
371
+ return readWorkIndexPage(store, now, access, { limit: 1, ...(root === undefined ? {} : { root }) }, taskId).items[0] ?? null;
372
+ }
373
+ function readWorkIndexPage(store, now, access, options, taskId = null) {
362
374
  const projection = registerValidators(store);
363
375
  const custody = custodyReadings(store);
364
376
  const view = VIEWS.includes(options.view ?? 'all') ? options.view ?? 'all' : 'all';
@@ -366,7 +378,7 @@ export function workIndexPage(store, now, access, options = {}) {
366
378
  const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
367
379
  const filter = view === 'needs-you' ? 'needs=1' : view === 'running' ? 'family_running=1' : view === 'completed' ? "code='complete'" : '1';
368
380
  const rows = prepared(store, `${projection}, selected_page AS MATERIALIZED (
369
- SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR
381
+ SELECT * FROM ranked WHERE ${filter} AND ($taskId IS NULL OR root_ref IN (SELECT root_ref FROM members WHERE id=$taskId)) AND ($cursorRoot=0 OR
370
382
  ${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
371
383
  ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
372
384
  ), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS},${GROUP_TOTALS} FROM ranked), project_totals AS (
@@ -379,7 +391,7 @@ export function workIndexPage(store, now, access, options = {}) {
379
391
  'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
380
392
  'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),'attempt_reason',(SELECT substr(reason,1,1000) FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),
381
393
  'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),
382
- 'result_role',(SELECT role FROM run WHERE id=page_result_id),'live_role',(SELECT role FROM run WHERE id=page.live_run),
394
+ 'result_role',(SELECT role FROM run WHERE id=page_result_id),'result_head',(SELECT head_revision FROM run WHERE id=page_result_id),'live_role',(SELECT role FROM run WHERE id=page.live_run),
383
395
  'check_status',(SELECT status FROM run_check WHERE run=page_result_id),
384
396
  'check_level',(SELECT outcome FROM action_ledger WHERE repo IS page.repo AND run_id=page_result_id AND action='checks used' ORDER BY id DESC LIMIT 1),
385
397
  'check_follows',(SELECT json_group_array(q.outcome||' '||COALESCE((SELECT substr(f.outcome,instr(f.outcome,':')+1) FROM action_ledger f WHERE f.repo IS page.repo AND f.run_id=page_result_id
@@ -392,7 +404,7 @@ export function workIndexPage(store, now, access, options = {}) {
392
404
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
393
405
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
394
406
  'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
395
- .all({ ...parameters(now, access, options, custody), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
407
+ .all({ ...parameters(now, access, options, custody), $taskId: taskId, $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
396
408
  const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
397
409
  const page = selected.slice(0, limit), last = page.at(-1);
398
410
  const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 2, scope,
@@ -405,7 +417,11 @@ export function workIndexPage(store, now, access, options = {}) {
405
417
  };
406
418
  // A claimed row says the viewer's own name for their lead.
407
419
  const leadName = page.some(row => n(row, 'lead_claim') !== 0) && access.principal === 'operator' && access.viewer != null ? leadNameOf(store, access.viewer) : undefined;
408
- return { items: page.map(row => itemOf(row, access.principal, probe, leadName)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
420
+ // A finished result reads the same readiness the task page does (task-status.ts): its requirements, acceptance,
421
+ // automatic review and, with checks Off, the release check that covers it.
422
+ const memo = new Map();
423
+ const readiness = (row, checks) => resultReadinessFacts(store, now, options.root, row, checks, memo);
424
+ return { items: page.map(row => itemOf(row, access.principal, probe, leadName, readiness)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
409
425
  groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
410
426
  }
411
427
  export function workCountsByProject(store, now, access) {
@@ -451,7 +467,30 @@ function listChecksOf(own, level, follows) {
451
467
  return null;
452
468
  return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
453
469
  }
454
- function itemOf(row, principal, probe, leadName) {
470
+ /** A ready result's requirements, acceptance and completion blockers from its saved records, as assignmentOf reads them. */
471
+ function resultReadinessFacts(store, now, root, row, recorded, memo) {
472
+ const run = n(row, 'result_id'), repo = s(row, 'repo');
473
+ if (!run)
474
+ return { checks: recorded };
475
+ const report = s(row, 'result_role') === 'scout';
476
+ // No recorded check result (a build from before results were recorded, or none ran): read its saved receipt, as the page does.
477
+ const saved = root !== undefined && (recorded === null || recorded.status === 'not-run') ? assignmentChecksForRun(store, root, run, now) : null;
478
+ const listed = saved === null ? recorded : { status: saved.status, exitCode: saved.exitCode, head: s(row, 'result_head'),
479
+ ...(saved.level == null ? {} : { level: saved.level }), ...(saved.running == null ? {} : { running: saved.running }), ...(saved.batch == null ? {} : { batch: saved.batch }) };
480
+ const checks = listed?.level === 'off' && listed.status !== 'passed' && listed.status !== 'failed'
481
+ ? { ...listed, release: releaseCoverageOf(store, root, repo, s(row, 'result_head'), { memo }) } : listed;
482
+ const proof = store.proofVerdictFor(run), accepted = store.proofAcceptance(run) !== null;
483
+ const review = buildReviewOf(store, run);
484
+ const high = review === null || review.sentBackAs !== null || review.state !== 'reviewed' ? 0 : review.high.length;
485
+ const checkRequired = requiredCheckCommandFor(store, repo, String(row['id']), checks?.level) !== null;
486
+ const backing = checkBackingOf(checks);
487
+ const requirements = requirementsOf(proof?.matrix, backing);
488
+ const verdict = shownVerdictOf(proof?.verdict ?? null, { checkFailed: !report && checks?.status === 'failed', highFindings: high, reviewPending: review?.state === 'pending',
489
+ criteriaPassed: requirements === null || requirements.met === requirements.total });
490
+ const blockers = completionBlockersOf({ report, checks, checkRequired, matrix: proof?.matrix, verdict: proof?.verdict ?? null, accepted, high });
491
+ return { checks, requirements: unverifiedWhenRefuted(requirements, verdict), ...(accepted ? { accepted } : {}), ...(blockers.length === 0 ? {} : { blockers }) };
492
+ }
493
+ function itemOf(row, principal, probe, leadName, readiness) {
455
494
  const code = String(row['code']), id = String(row['id']);
456
495
  const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
457
496
  const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
@@ -564,10 +603,13 @@ function itemOf(row, principal, probe, leadName) {
564
603
  const checkStatus = s(row, 'check_status'), verdict = s(row, 'proof_verdict');
565
604
  const finished = reading.stage === 'finished' || reading.stage === 'complete';
566
605
  const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' || verdict === 'verified' ? 'passed' : null;
606
+ const listedChecks = !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']);
607
+ const ready = reading.stage === 'finished' && readiness !== undefined ? readiness(row, listedChecks) : null;
567
608
  const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }), ...(reading.wait === undefined ? {} : { wait: reading.wait }),
568
609
  ...(process === null ? {} : { needContext: { build: process.build } }),
569
610
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
570
- checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
611
+ checks: ready?.checks ?? listedChecks,
612
+ ...(ready === null ? {} : { requirements: ready.requirements ?? null, ...(ready.accepted ? { accepted: true } : {}), ...(ready.blockers === undefined ? {} : { blockers: ready.blockers }) }),
571
613
  completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
572
614
  ...(lead === null ? {} : { lead, ...(leadName === undefined ? {} : { leadName }) }) });
573
615
  label = shared.headline;
@@ -395,7 +395,8 @@ export function acceptWordsOf(facts) {
395
395
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
396
396
  const parts = [
397
397
  ...(facts.proof ? [] : ["nothing on record says what was met"]),
398
- ...(facts.checks === "passed" ? [] : [facts.checks === "failed" ? "checks failed" : facts.checks === "off" ? "checks are off for this project"
398
+ // Checks deliberately Off are checked at release (task-status.ts): not a gap here.
399
+ ...(facts.checks === "passed" || facts.checks === "off" ? [] : [facts.checks === "failed" ? "checks failed"
399
400
  : facts.checks === "running" ? "checks are still running" : facts.checks === "unavailable" ? "saved checks can't be read" : "checks didn't run"]),
400
401
  ...(facts.unmet > 0 ? [`${plural(facts.unmet, "requirement isn't", "requirements aren't")} met`] : []),
401
402
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.56",
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
- }