toolroll 0.9.57 → 0.9.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/action-ledger.js +10 -5
- package/dist/actor.d.ts +0 -10
- package/dist/actor.js +1 -18
- package/dist/api-tokens.d.ts +33 -2
- package/dist/api-tokens.js +39 -11
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.js +3 -2
- package/dist/browser/workspace.js +1 -1
- package/dist/builder.js +4 -2
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +3 -0
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/export.js +2 -2
- package/dist/lead-cli.js +3 -0
- package/dist/lead-doors.js +3 -0
- package/dist/lead-tools.js +2 -1
- package/dist/mcp-http.js +5 -5
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +23 -11
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/remote-command.js +0 -2
- package/dist/remote-exec.d.ts +19 -0
- package/dist/remote-exec.js +9 -8
- package/dist/request-budget.d.ts +1 -1
- package/dist/scope.js +16 -1
- package/dist/serve.js +15 -0
- package/dist/server/edge-refusal.d.ts +2 -0
- package/dist/server/edge-refusal.js +8 -6
- package/dist/server/guards.js +5 -4
- package/dist/server/http.d.ts +2 -0
- package/dist/server/remote.js +6 -13
- package/dist/server/render-chat.js +10 -0
- package/dist/server/render-tasks.js +19 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +3 -1
- package/dist/session-cli.d.ts +38 -9
- package/dist/session-cli.js +141 -154
- package/dist/session-contract.js +2 -2
- package/dist/session-remote.d.ts +19 -0
- package/dist/session-remote.js +86 -0
- package/dist/store.d.ts +72 -12
- package/dist/store.js +367 -105
- package/dist/task-status.js +3 -1
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +10 -1
- package/package.json +1 -1
- package/dist/session-http.d.ts +0 -24
- package/dist/session-http.js +0 -170
- package/dist/session-server.d.ts +0 -17
- package/dist/session-server.js +0 -39
package/dist/task-status.js
CHANGED
|
@@ -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"],
|
|
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"],
|
package/dist/team-cli.d.ts
CHANGED
|
@@ -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' ||
|
|
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 =
|
|
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);
|
package/dist/telegram-lead.js
CHANGED
|
@@ -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}`,
|
package/dist/toolroll-update.js
CHANGED
|
@@ -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.
|
|
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"
|
package/dist/session-http.d.ts
DELETED
|
@@ -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>;
|
package/dist/session-http.js
DELETED
|
@@ -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
|
-
}
|
package/dist/session-server.d.ts
DELETED
|
@@ -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>;
|
package/dist/session-server.js
DELETED
|
@@ -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
|
-
}
|