@ours.network/fleet 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +41 -2
  2. package/dist/application/conversation-history.d.ts +8 -0
  3. package/dist/application/conversation-history.js +37 -2
  4. package/dist/application/task-room-service.d.ts +7 -0
  5. package/dist/application/task-room-service.js +19 -0
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1 -0
  9. package/dist/doctor.js +17 -4
  10. package/dist/notifications/fleet-producer.d.ts +0 -1
  11. package/dist/notifications/fleet-producer.js +65 -47
  12. package/dist/notifications/mission-ownership.d.ts +31 -0
  13. package/dist/notifications/mission-ownership.js +148 -0
  14. package/dist/notifications/outbox.d.ts +10 -0
  15. package/dist/notifications/outbox.js +28 -2
  16. package/dist/notifications/task-producer.d.ts +11 -1
  17. package/dist/notifications/task-producer.js +34 -5
  18. package/dist/rooms-tasks/layout-service.d.ts +2 -0
  19. package/dist/rooms-tasks/layout-service.js +7 -2
  20. package/dist/rooms-tasks/layout.d.ts +5 -1
  21. package/dist/rooms-tasks/layout.js +7 -1
  22. package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
  23. package/dist/rooms-tasks/notification-manifest.js +65 -0
  24. package/dist/rooms-tasks/task-review.d.ts +2 -0
  25. package/dist/rooms-tasks/task-review.js +7 -5
  26. package/dist/rooms-tasks/task-state.d.ts +35 -0
  27. package/dist/rooms-tasks/task-state.js +151 -17
  28. package/dist/rooms-tasks/types.d.ts +10 -0
  29. package/dist/runtime-security.d.ts +1 -0
  30. package/dist/runtime-security.js +6 -0
  31. package/dist/security-versions.d.ts +20 -0
  32. package/dist/security-versions.js +70 -0
  33. package/dist/session/acp.d.ts +3 -0
  34. package/dist/session/acp.js +58 -1
  35. package/dist/session/codex-app-server.js +22 -1
  36. package/dist/session/conversation-types.d.ts +8 -2
  37. package/dist/session/session-ownership.d.ts +18 -0
  38. package/dist/session/session-ownership.js +74 -0
  39. package/dist/setup-tunnel-preflight.js +10 -3
  40. package/dist/supervisor/manager.js +9 -2
  41. package/dist/web/resource-views.d.ts +2 -0
  42. package/dist/web/resource-views.js +4 -2
  43. package/dist/web/runtime.js +1 -1
  44. package/dist/web/server.js +29 -8
  45. package/dist/web/workspace-tunnel.d.ts +2 -1
  46. package/dist/web/workspace-tunnel.js +29 -3
  47. package/package.json +6 -6
@@ -8,6 +8,7 @@ import { stateRoot } from '../paths.js';
8
8
  import { planWorkspace, ensureWorkspace } from './workspace.js';
9
9
  import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
10
10
  import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
11
+ import { queueMissionCleanup, withManifestRooms } from './notification-manifest.js';
11
12
  export const tasksDir = () => join(stateRoot(), 'tasks');
12
13
  function taskPath(id) { return join(tasksDir(), `${id}.json`); }
13
14
  function taskLockPath(id) { return join(tasksDir(), '.locks', id); }
@@ -165,14 +166,24 @@ function writeTask(record) {
165
166
  const status = (task) => task.deletion?.status === 'pending' ? 'deleting' : task.blocked ? `${task.state} (blocked)` : task.state;
166
167
  // Read pending events under the same task lock as every mutation. Metadata edits
167
168
  // and retries preserve the queue; acknowledgement cannot be resurrected by stale input.
168
- const pending = [...(previous?.notification_events ?? [])];
169
- if (!previous || status(previous) !== status(record)) {
170
- const next = status(record), before = previous ? status(previous) : undefined;
171
- pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
172
- title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
173
- body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
174
- target: { kind: 'task', taskId: record.task_id } });
169
+ let pending = [...(previous?.notification_events ?? [])];
170
+ if (record.deletion?.status === 'pending') {
171
+ // Deletion acceptance is the notification fence: queued transitions are obsolete and nothing new is produced.
172
+ pending = [];
175
173
  }
174
+ else {
175
+ if (!previous || status(previous) !== status(record)) {
176
+ const next = status(record), before = previous ? status(previous) : undefined;
177
+ pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
178
+ title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
179
+ body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
180
+ target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
181
+ }
182
+ pending.push(...contributionEvents(previous, record));
183
+ }
184
+ if (previous && !previous.contribution_reads && contributionsChanged(previous, record))
185
+ // First post-upgrade change: everything that existed before it counts as read, under this same lock.
186
+ persisted.contribution_reads = { progress_read: (previous.progress ?? []).map(entry => entry.id), review_revision: reviewRevision(previous.review) };
176
187
  if (pending.length)
177
188
  persisted.notification_events = pending;
178
189
  else
@@ -180,9 +191,76 @@ function writeTask(record) {
180
191
  replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
181
192
  publishFleetResourceCommit(taskPath(record.task_id));
182
193
  }
194
+ /** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
195
+ export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
196
+ const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
197
+ || reviewRevision(record.review) !== reviewRevision(previous.review);
198
+ /** New Progress entries and new To review revisions push once each; reads and replays produce nothing. */
199
+ function contributionEvents(previous, record) {
200
+ if (!previous)
201
+ return [];
202
+ const id = record.task_id, events = [];
203
+ for (const entry of record.progress ?? []) {
204
+ if ((previous.progress ?? []).some(old => old.id === entry.id))
205
+ continue;
206
+ events.push({ eventId: `task:${id}:progress:${entry.id}`, taskId: id, title: `Progress: ${record.title}`.slice(0, 160),
207
+ body: (entry.text.trim() || 'New progress entry').slice(0, 512), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
208
+ }
209
+ const revision = reviewRevision(record.review);
210
+ if (revision > reviewRevision(previous.review))
211
+ events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${record.title}`.slice(0, 160),
212
+ body: `Task ${id}: the review package changed.`, target: { kind: 'task', taskId: id }, delivery: 'push-only' });
213
+ return events;
214
+ }
215
+ /** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
216
+ export function contributionUnread(task) {
217
+ const reads = task.contribution_reads;
218
+ if (!reads)
219
+ return { progress: 0, review: false };
220
+ const seen = new Set(reads.progress_read);
221
+ return { progress: (task.progress ?? []).filter(entry => !seen.has(entry.id)).length, review: reviewRevision(task.review) > reads.review_revision };
222
+ }
223
+ /** Acknowledge Progress entries the user actually viewed; unknown IDs are ignored and the set only grows. */
224
+ export function acknowledgeTaskProgress(id, ids) {
225
+ assertCanonicalTaskId(id);
226
+ return withTaskLock(id, () => {
227
+ const task = readTask(id);
228
+ assertNoPendingDeletion(task);
229
+ // Without a read state everything present already counts as read: there is nothing to record.
230
+ const reads = task.contribution_reads;
231
+ if (!reads)
232
+ return contributionUnread(task);
233
+ const present = new Set((task.progress ?? []).map(entry => entry.id));
234
+ const added = [...new Set(ids.filter(entryId => present.has(entryId) && !reads.progress_read.includes(entryId)))];
235
+ if (added.length) {
236
+ task.contribution_reads = { ...reads, progress_read: [...reads.progress_read, ...added] };
237
+ writeTask(task);
238
+ }
239
+ return contributionUnread(task);
240
+ });
241
+ }
242
+ /** Acknowledge the To review revision the user saw. Only moves forward; a revision newer than current is refused. */
243
+ export function acknowledgeTaskReview(id, revision) {
244
+ assertCanonicalTaskId(id);
245
+ return withTaskLock(id, () => {
246
+ const task = readTask(id);
247
+ assertNoPendingDeletion(task);
248
+ const current = reviewRevision(task.review);
249
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision > current)
250
+ throw new TaskStateError('That review revision does not exist.');
251
+ const reads = task.contribution_reads;
252
+ if (reads && revision > reads.review_revision) {
253
+ task.contribution_reads = { ...reads, review_revision: Math.max(reads.review_revision, revision) };
254
+ writeTask(task);
255
+ }
256
+ return contributionUnread(task);
257
+ });
258
+ }
183
259
  const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
184
260
  function typedTaskNotification(value, id) {
185
- if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId'].includes(key)))
261
+ if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId', 'delivery'].includes(key)))
262
+ return false;
263
+ if (value.delivery !== undefined && value.delivery !== 'push-only')
186
264
  return false;
187
265
  const target = value.target;
188
266
  return typeof value.eventId === 'string' && value.eventId.startsWith(`task:${id}:`) && value.eventId.length <= 512
@@ -193,9 +271,13 @@ function typedTaskNotification(value, id) {
193
271
  && typeof value.body === 'string' && !!value.body.trim() && value.body.length <= 512;
194
272
  }
195
273
  /** Atomic snapshots contain the events, including intermediate states between polls. */
274
+ /** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
275
+ * same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
276
+ * already admitted one as an inbox row repairs that row to push-only itself, so the retry matches; any refusal keeps the
277
+ * event queued. */
196
278
  export function pendingTaskNotifications(warn = () => { }) {
197
279
  const result = [];
198
- for (const dir of [tasksDir(), retiredNotificationsDir()]) {
280
+ for (const dir of [tasksDir()]) {
199
281
  if (!existsSync(dir))
200
282
  continue;
201
283
  for (const file of readdirSync(dir).filter(file => TASK_ID_PATTERN.test(file.slice(0, -5)) && file.endsWith('.json'))) {
@@ -214,9 +296,11 @@ export function pendingTaskNotifications(warn = () => { }) {
214
296
  throw new Error('invalid notification events');
215
297
  // Only typed task events are delivered. Unsupported entries are never sent or converted,
216
298
  // and they do not block later lifecycle events.
217
- const events = value.notification_events.filter(event => typedTaskNotification(event, id));
299
+ const typed = value.notification_events.filter(event => typedTaskNotification(event, id));
300
+ const upgraded = typed.filter(event => event.delivery === undefined).map(event => event.eventId);
301
+ const events = typed.map(event => event.delivery ? event : { ...event, delivery: 'push-only' });
218
302
  if (events.length)
219
- result.push({ taskId: id, events });
303
+ result.push({ taskId: id, events, upgraded });
220
304
  }
221
305
  catch (error) {
222
306
  if (!isNotFoundError(error))
@@ -226,6 +310,25 @@ export function pendingTaskNotifications(warn = () => { }) {
226
310
  }
227
311
  return result;
228
312
  }
313
+ /** Earlier releases kept a deleted task's queued events in `.notification-retired`. They are obsolete: each
314
+ * becomes a cleanup intent for its task and is never delivered. Returns the number converted. */
315
+ export function convertRetiredTaskNotifications() {
316
+ const dir = retiredNotificationsDir();
317
+ if (!existsSync(dir))
318
+ return 0;
319
+ let converted = 0;
320
+ for (const file of readdirSync(dir).filter(file => file.endsWith('.json') && TASK_ID_PATTERN.test(file.slice(0, -5)))) {
321
+ const id = file.slice(0, -5);
322
+ withTaskLock(id, () => {
323
+ if (existsSync(taskPath(id)))
324
+ return; // a prepared copy has no authority while the task exists
325
+ queueMissionCleanup(id, []);
326
+ rmSync(join(dir, file), { force: true });
327
+ converted++;
328
+ });
329
+ }
330
+ return converted;
331
+ }
229
332
  /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
230
333
  export function acknowledgeTaskNotification(id, eventId) {
231
334
  assertCanonicalTaskId(id);
@@ -583,10 +686,41 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
583
686
  assertNoPendingTerminalIntent(t);
584
687
  t.room_id = roomId;
585
688
  t.room_identity_cid = roomIdentityCid;
689
+ t.notification_manifest = withManifestRooms(t.notification_manifest, [roomIdentityCid]);
586
690
  writeTask(t);
587
691
  return t;
588
692
  });
589
693
  }
694
+ /** Record room CIDs as this task's notification-owned rooms (union only). Returns the manifest to register. */
695
+ export function recordNotificationRooms(id, cids) {
696
+ assertCanonicalTaskId(id);
697
+ return withTaskLock(id, () => {
698
+ const t = readTask(id);
699
+ assertNoPendingDeletion(t);
700
+ const next = withManifestRooms(t.notification_manifest, cids);
701
+ if (next !== t.notification_manifest) {
702
+ t.notification_manifest = next;
703
+ writeTask(t);
704
+ }
705
+ return next;
706
+ });
707
+ }
708
+ /** After a refused registration (409), move the manifest revision past the refused one; rooms are unchanged. A retry
709
+ * then registers the full union under a newer revision. Nothing happens if the manifest already moved on. */
710
+ export function advanceNotificationRevision(id, refused) {
711
+ assertCanonicalTaskId(id);
712
+ withTaskLock(id, () => {
713
+ const t = readTask(id);
714
+ if (t.deletion?.status === 'pending' || t.notification_manifest?.revision !== refused)
715
+ return;
716
+ t.notification_manifest = { ...t.notification_manifest, revision: refused + 1 };
717
+ writeTask(t);
718
+ });
719
+ }
720
+ /** Every room this task ever recorded, including the current one from records written before manifests existed. */
721
+ export function notificationRoomCids(task) {
722
+ return withManifestRooms(task.notification_manifest, task.room_identity_cid ? [task.room_identity_cid] : []).room_cids;
723
+ }
590
724
  export function updateTaskTemplate(id, template) {
591
725
  return withTaskLock(id, () => {
592
726
  const t = readTask(id);
@@ -729,6 +863,7 @@ export function beginTaskDeletionIntent(id, actor) {
729
863
  // Heal a crash between the intent write and the receipt write.
730
864
  if (!readTaskDeletionReceipt(id))
731
865
  writeDeletionReceiptForIntent(stored);
866
+ queueMissionCleanup(id, notificationRoomCids(stored));
732
867
  return { status: 'pending', task: presentTaskLenient(stored) };
733
868
  }
734
869
  const now = new Date().toISOString();
@@ -741,7 +876,9 @@ export function beginTaskDeletionIntent(id, actor) {
741
876
  name: member.name, identity_cid: member.identity_cid, phase: 'pending', updated_at: now,
742
877
  })),
743
878
  };
879
+ // The task write drops queued transitions; the cleanup intent then retires everything already delivered.
744
880
  writeTask(stored);
881
+ queueMissionCleanup(id, notificationRoomCids(stored));
745
882
  writeDeletionReceiptForIntent(stored);
746
883
  return { status: 'accepted', task: presentTaskLenient(stored) };
747
884
  });
@@ -967,12 +1104,9 @@ export function unlinkDeletedTask(id) {
967
1104
  }
968
1105
  if (stored.deletion?.status !== 'pending')
969
1106
  throw new TaskStateError(`task ${id} has no pending deletion`);
970
- // Preserve committed deliveries outside the record before deletion. A crash
971
- // before unlink may replay two copies; both carry identical service dedupe IDs.
972
- const removed = { eventId: `task:${id}:deleted`, taskId: id,
973
- title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
974
- target: { kind: 'task-deleted', taskId: id } };
975
- replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
1107
+ // Merge rooms proven since acceptance into the durable cleanup intent before the record disappears.
1108
+ // No deletion notification is produced: deleting retires the task's notifications instead.
1109
+ queueMissionCleanup(id, notificationRoomCids(stored));
976
1110
  try {
977
1111
  unlinkSync(taskPath(id));
978
1112
  }
@@ -115,6 +115,13 @@ export interface TaskNotificationEvent {
115
115
  taskId: string;
116
116
  };
117
117
  taskId: string;
118
+ /** Born read in the notification service: pushed, never an unread item. Absent on events queued before it. */
119
+ delivery?: 'push-only';
120
+ }
121
+ /** Persisted reads of Progress entries and To review revisions; absent until the first post-upgrade change. */
122
+ export interface TaskContributionReads {
123
+ progress_read: string[];
124
+ review_revision: number;
118
125
  }
119
126
  /** Who wrote a progress entry or review change, as established by the Fleet surface. */
120
127
  export type TaskContributionAuthor = {
@@ -162,6 +169,9 @@ export interface TaskRecord {
162
169
  review?: TaskReview;
163
170
  /** Pending lifecycle events, committed atomically with their task change. */
164
171
  notification_events?: TaskNotificationEvent[];
172
+ contribution_reads?: TaskContributionReads;
173
+ /** Rooms whose notifications belong to this task; only grows. */
174
+ notification_manifest?: import('./notification-manifest.js').TaskNotificationManifest;
165
175
  workspace?: import('./workspace.js').OwnedWorkspace;
166
176
  task_id: string;
167
177
  /** Stable organizational list identifier. Missing legacy values mean `default`. */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ // Imported first by the CLI entry point: refuse an unpatched Node.js before any other module runs.
2
+ import { isSecureNodeVersion, nodeVersionError } from './security-versions.js';
3
+ if (!isSecureNodeVersion(process.versions.node)) {
4
+ process.stderr.write(`${nodeVersionError(process.versions.node)}\n`);
5
+ process.exit(1);
6
+ }
@@ -0,0 +1,20 @@
1
+ export declare const NODE_PATCHED_FLOORS: Readonly<Record<number, readonly [number, number, number]>>;
2
+ export declare const NODE_SECURITY_ADVISORY = "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases";
3
+ export declare const NODE_DOWNLOAD_URL = "https://nodejs.org/en/download";
4
+ export declare const NODE_SECURITY_REQUIREMENT: string;
5
+ export declare const CLOUDFLARED_MIN_VERSION: readonly number[];
6
+ export declare const CLOUDFLARED_SECURITY_ADVISORY = "https://blog.cloudflare.com/emudhra-certificate-compromise/";
7
+ export declare const CLOUDFLARED_UPDATE_URL = "https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/update-cloudflared/";
8
+ export declare const CLOUDFLARED_VERSION_TIMEOUT_MS = 10000;
9
+ /** Returns [major, minor, patch] for a stable Node.js version string, otherwise null. */
10
+ export declare function parseNodeVersion(version: string): number[] | null;
11
+ export declare function isSecureNodeVersion(version: string): boolean;
12
+ export declare function nodeVersionError(version: string): string;
13
+ export declare function assertSecureNodeVersion(version?: string): void;
14
+ /** Returns [year, month, patch] for exact stable `cloudflared --version` output, otherwise null. */
15
+ export declare function parseCloudflaredVersion(output: string): number[] | null;
16
+ /** `reason` must be a fixed description such as "not found" or "timed out", never command output. */
17
+ export declare function cloudflaredUnavailableError(reason: string): string;
18
+ export declare function cloudflaredVersionError(output: string): string;
19
+ export declare function isSecureCloudflaredVersion(output: string): boolean;
20
+ export declare function assertSecureCloudflaredVersion(output: string): void;
@@ -0,0 +1,70 @@
1
+ // Security version floors for the runtimes Ours executes. Keep this file
2
+ // identical in ours-network, ours-fleet and ours-app.
3
+ //
4
+ // Node.js policy: only reviewed stable release lines are admitted, each with
5
+ // its own patched floor. A new line (for example a future 27 or 28 release) is
6
+ // added by a reviewed change once its security releases have been checked;
7
+ // until then it is refused, as are prerelease builds, end-of-life lines and
8
+ // earlier patch levels of a listed line. A single global ">=" comparison would
9
+ // admit vulnerable intermediate lines, so every line keeps an explicit floor.
10
+ export const NODE_PATCHED_FLOORS = Object.freeze({ 22: [22, 23, 4], 24: [24, 22, 1], 26: [26, 12, 1] });
11
+ export const NODE_SECURITY_ADVISORY = 'https://nodejs.org/en/blog/vulnerability/october-2026-security-releases';
12
+ export const NODE_DOWNLOAD_URL = 'https://nodejs.org/en/download';
13
+ export const NODE_SECURITY_REQUIREMENT = Object.values(NODE_PATCHED_FLOORS).map(floor => `${floor.join('.')}+`).join(', ');
14
+ // cloudflared releases before 2026.10.2 trust compromised eMudhra intermediates.
15
+ export const CLOUDFLARED_MIN_VERSION = Object.freeze([2026, 10, 2]);
16
+ export const CLOUDFLARED_SECURITY_ADVISORY = 'https://blog.cloudflare.com/emudhra-certificate-compromise/';
17
+ export const CLOUDFLARED_UPDATE_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/update-cloudflared/';
18
+ export const CLOUDFLARED_VERSION_TIMEOUT_MS = 10_000;
19
+ const atLeast = (version, floor) => {
20
+ for (let i = 0; i < floor.length; i++)
21
+ if (version[i] !== floor[i])
22
+ return version[i] > floor[i];
23
+ return true;
24
+ };
25
+ /** Returns [major, minor, patch] for a stable Node.js version string, otherwise null. */
26
+ export function parseNodeVersion(version) {
27
+ const match = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})$/.exec(String(version));
28
+ return match ? match.slice(1).map(Number) : null;
29
+ }
30
+ export function isSecureNodeVersion(version) {
31
+ const parsed = parseNodeVersion(version);
32
+ const floor = parsed && NODE_PATCHED_FLOORS[parsed[0]];
33
+ return Boolean(floor && atLeast(parsed, floor));
34
+ }
35
+ export function nodeVersionError(version) {
36
+ const shown = /^v?[0-9A-Za-z.+-]{1,64}$/.test(String(version)) ? String(version).replace(/^v/, '') : 'unrecognized';
37
+ return `Node.js ${shown} is not permitted. Ours requires a patched supported Node.js release: ${NODE_SECURITY_REQUIREMENT} `
38
+ + 'within those release lines; prerelease, end-of-life and unreviewed release lines are refused. '
39
+ + `Earlier patch levels are affected by CVE-2026-111497 (Brotli decompression out-of-bounds write) and the other October 2026 fixes: ${NODE_SECURITY_ADVISORY} `
40
+ + `Update Node.js from ${NODE_DOWNLOAD_URL} or your package manager, then retry.`;
41
+ }
42
+ export function assertSecureNodeVersion(version = process.versions.node) {
43
+ if (!isSecureNodeVersion(version))
44
+ throw new Error(nodeVersionError(version));
45
+ }
46
+ /** Returns [year, month, patch] for exact stable `cloudflared --version` output, otherwise null. */
47
+ export function parseCloudflaredVersion(output) {
48
+ const match = /^cloudflared version (\d{4})\.(\d{1,2})\.(\d{1,6})(?: \(built [^\r\n()]*\))?\r?\n?$/.exec(String(output));
49
+ const parsed = match?.slice(1).map(Number);
50
+ return parsed && parsed[1] >= 1 && parsed[1] <= 12 ? parsed : null;
51
+ }
52
+ const cloudflaredGuidance = () => `cloudflared releases before ${CLOUDFLARED_MIN_VERSION.join('.')} trust compromised eMudhra intermediate certificates, which allows server impersonation: ${CLOUDFLARED_SECURITY_ADVISORY} `
53
+ + `Update the cloudflared executable on this host's PATH (${CLOUDFLARED_UPDATE_URL}, or your package manager), check it with "cloudflared --version", then retry.`;
54
+ /** `reason` must be a fixed description such as "not found" or "timed out", never command output. */
55
+ export function cloudflaredUnavailableError(reason) {
56
+ return `cloudflared could not be verified (${reason}). A tunnel requires cloudflared ${CLOUDFLARED_MIN_VERSION.join('.')} or later. ${cloudflaredGuidance()}`;
57
+ }
58
+ export function cloudflaredVersionError(output) {
59
+ const parsed = parseCloudflaredVersion(output);
60
+ const found = parsed ? `version ${parsed.join('.')}` : 'with an unrecognized version';
61
+ return `cloudflared ${found} is not permitted; a tunnel requires cloudflared ${CLOUDFLARED_MIN_VERSION.join('.')} or later. ${cloudflaredGuidance()}`;
62
+ }
63
+ export function isSecureCloudflaredVersion(output) {
64
+ const parsed = parseCloudflaredVersion(output);
65
+ return Boolean(parsed && atLeast(parsed, CLOUDFLARED_MIN_VERSION));
66
+ }
67
+ export function assertSecureCloudflaredVersion(output) {
68
+ if (!isSecureCloudflaredVersion(output))
69
+ throw new Error(cloudflaredVersionError(output));
70
+ }
@@ -208,6 +208,9 @@ export declare class AcpSession implements AgentSession {
208
208
  private activeTurn?;
209
209
  private constructor();
210
210
  static start(options: AcpSessionOptions): Promise<AcpSession>;
211
+ /** Construction failed after the child and connection exist: close the connection, stop the child (bounded TERM, then
212
+ * KILL), and check liveness at the end. Every cleanup failure is kept; a child not proven exited is named by pid. */
213
+ private static releaseFailedStart;
211
214
  /**
212
215
  * Honest restart recovery: a prompt that was admitted but never
213
216
  * started is safe to dispatch again; a turn that had already started may
@@ -1,4 +1,5 @@
1
1
  import { ConversationObservation } from './conversation-observation.js';
2
+ import { recordSessionOwnership } from './session-ownership.js';
2
3
  import { appendTaskCreated } from './task-notice.js';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { createHash, randomUUID } from 'node:crypto';
@@ -336,7 +337,17 @@ export class AcpSession {
336
337
  this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
337
338
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
338
339
  });
340
+ // Only earlier generations are below the page floor. The current ownership
341
+ // binding must be visible through both canonical pages and passive replay.
339
342
  this.conversationStartCursor = this.conversation.lastCursor();
343
+ // Unavailable ownership refuses the start; release the ledger descriptor this constructor opened.
344
+ try {
345
+ recordSessionOwnership(this.conversation, this.sessionGeneration, options.stateDir);
346
+ }
347
+ catch (error) {
348
+ this.conversation.close();
349
+ throw error;
350
+ }
340
351
  // A durable append can precede its inventory mutation. Capture only after
341
352
  // the synchronous owner checkpoint, never the state at append notification.
342
353
  this.conversation.subscribe(() => this.observation.scheduleCapture());
@@ -417,7 +428,14 @@ export class AcpSession {
417
428
  });
418
429
  const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
419
430
  const connection = app.connect(stream);
420
- instance = new AcpSession(options, child, connection);
431
+ try {
432
+ instance = new AcpSession(options, child, connection);
433
+ }
434
+ catch (error) {
435
+ // A cleanup exception never replaces the ownership failure: both are kept.
436
+ throw await AcpSession.releaseFailedStart(child, connection, error)
437
+ .catch(cleanupError => new AggregateError([error, cleanupError], 'ACP start failed and its cleanup failed', { cause: error }));
438
+ }
421
439
  try {
422
440
  await instance.initialize();
423
441
  instance.startStallWatchdog();
@@ -430,6 +448,45 @@ export class AcpSession {
430
448
  throw error;
431
449
  }
432
450
  }
451
+ /** Construction failed after the child and connection exist: close the connection, stop the child (bounded TERM, then
452
+ * KILL), and check liveness at the end. Every cleanup failure is kept; a child not proven exited is named by pid. */
453
+ static async releaseFailedStart(child, connection, error) {
454
+ const failures = [];
455
+ try {
456
+ connection.close();
457
+ }
458
+ catch (closeError) {
459
+ failures.push(closeError);
460
+ }
461
+ const exited = () => child.exitCode !== null || child.signalCode !== null;
462
+ const wait = (ms) => new Promise(resolve => {
463
+ if (exited())
464
+ return resolve();
465
+ const settle = () => { clearTimeout(timer); child.off('exit', settle); resolve(); };
466
+ const timer = setTimeout(settle, ms);
467
+ timer.unref();
468
+ child.on('exit', settle);
469
+ });
470
+ let killFailed = false;
471
+ const signal = (name) => { try {
472
+ child.kill(name);
473
+ }
474
+ catch (killError) {
475
+ killFailed = true;
476
+ failures.push(killError);
477
+ } };
478
+ if (!exited()) {
479
+ signal('SIGTERM');
480
+ await wait(2_500);
481
+ }
482
+ if (!exited()) {
483
+ signal('SIGKILL');
484
+ await wait(2_500);
485
+ }
486
+ if (!exited())
487
+ failures.push(new Error(`ACP child (pid ${child.pid}) ${killFailed ? 'exit unproven: a stop signal failed' : 'is still running'}`));
488
+ return failures.length ? new AggregateError([error, ...failures], 'ACP start failed and its release is unproven', { cause: error }) : error;
489
+ }
433
490
  /**
434
491
  * Honest restart recovery: a prompt that was admitted but never
435
492
  * started is safe to dispatch again; a turn that had already started may
@@ -1,4 +1,5 @@
1
1
  import { ConversationObservation } from './conversation-observation.js';
2
+ import { recordSessionOwnership } from './session-ownership.js';
2
3
  import { appendTaskCreated } from './task-notice.js';
3
4
  import { createHash, randomUUID } from 'node:crypto';
4
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -173,7 +174,17 @@ export class CodexAppServerSession {
173
174
  this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
174
175
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
175
176
  });
177
+ // Only earlier generations are below the page floor. The current ownership
178
+ // binding must be visible through both canonical pages and passive replay.
176
179
  this.conversationStartCursor = this.conversation.lastCursor();
180
+ // Unavailable ownership refuses the start; release the ledger descriptor this constructor opened.
181
+ try {
182
+ recordSessionOwnership(this.conversation, this.sessionGeneration, options.stateDir);
183
+ }
184
+ catch (error) {
185
+ this.conversation.close();
186
+ throw error;
187
+ }
177
188
  // A durable append can precede its inventory mutation. Capture only after
178
189
  // the synchronous owner checkpoint, never the state at append notification.
179
190
  this.conversation.subscribe(() => this.observation.scheduleCapture());
@@ -191,7 +202,17 @@ export class CodexAppServerSession {
191
202
  onRequest: (method, id, params) => instance?.serverRequest(method, id, params),
192
203
  onExit: exit => instance?.transportExited(exit),
193
204
  });
194
- instance = new CodexAppServerSession(options, transport);
205
+ try {
206
+ instance = new CodexAppServerSession(options, transport);
207
+ }
208
+ catch (error) {
209
+ // Existing bounded TERM→KILL; its waits are not proof, so liveness is checked after it returns.
210
+ const failures = [];
211
+ await transport.close().catch((closeError) => { failures.push(closeError); });
212
+ if (transport.isAlive())
213
+ failures.push(new Error(`Codex app-server transport (pid ${transport.pid}) is still running`));
214
+ throw failures.length ? new AggregateError([error, ...failures], 'Codex app-server start failed and its release is unproven', { cause: error }) : error;
215
+ }
195
216
  try {
196
217
  await instance.initialize();
197
218
  instance.recoverOpenPrompts();
@@ -8,7 +8,7 @@ import type { PromptOrigin, SessionSnapshot, TurnCancellationSource, TurnOutcome
8
8
  * file touches the wire: transport updates are reduced into these shapes by a
9
9
  * session adapter, and the store assigns `seq`/`eventId`/timestamps.
10
10
  */
11
- export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
11
+ export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'session.ownership' | 'capabilities.updated' | 'error'
12
12
  /** A well-formed transport update this version cannot represent. Bounded, never a crash. */
13
13
  | 'unsupported';
14
14
  /** Where a conversation record came from. Typed provenance, never prompt text. */
@@ -142,6 +142,12 @@ export interface UsageUpdatedPayload {
142
142
  export interface SessionStatePayload {
143
143
  currentModeId?: string;
144
144
  }
145
+ /** First event of a generation: the launch it belongs to and that launch's task, or null for a non-task role. */
146
+ export interface SessionOwnershipPayload {
147
+ /** Null for a role without temp-supervisor metadata (explicitly standalone). */
148
+ launchId: string | null;
149
+ taskId: string | null;
150
+ }
145
151
  export interface SessionInfoPayload {
146
152
  title?: string | null;
147
153
  updatedAt?: string | null;
@@ -236,7 +242,7 @@ export interface BoundedJson {
236
242
  }
237
243
  export type ConversationPayload = {
238
244
  attachment: import('../file-delivery/types.js').DeliveredFile;
239
- } | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
245
+ } | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | SessionOwnershipPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
240
246
  export interface ConversationEventV1 {
241
247
  schemaVersion: 1;
242
248
  roleId: string;
@@ -0,0 +1,18 @@
1
+ import type { ConversationEventStore } from './conversation-store.js';
2
+ /** Durable generation → launch/task evidence, independent of the transcript tail. Bounded to recent generations. */
3
+ export declare const SESSION_OWNERSHIP_FILE = ".session-ownership.json";
4
+ export interface SessionOwnership {
5
+ launchId: string | null;
6
+ taskId: string | null;
7
+ }
8
+ /** Canonical Fleet task identity; a task owner always has a launch. */
9
+ export declare const validOwnerTask: (taskId: unknown) => taskId is string;
10
+ /** Absent evidence is `{}` (no file: ENOENT only). Unreadable or invalid evidence throws and is never treated as absent. */
11
+ export declare function readSessionOwnership(stateDir: string): Record<string, SessionOwnership>;
12
+ /**
13
+ * Bind this session generation to its launch before any prompt can be admitted. A task member's generation names
14
+ * its task; a role without temp-supervisor metadata is explicitly standalone. Existing but unreadable metadata is
15
+ * unavailable evidence: the session refuses to start rather than guess. For a task-owned generation both the
16
+ * sidecar and the ledger event must persist, or the session start fails (no prompt is admitted without its binding).
17
+ */
18
+ export declare function recordSessionOwnership(conversation: ConversationEventStore, sessionGeneration: string, stateDir: string): void;
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { readTempSupervisor, TEMP_SUPERVISOR_FILE } from '../temp-lifecycle.js';
5
+ /** Durable generation → launch/task evidence, independent of the transcript tail. Bounded to recent generations. */
6
+ export const SESSION_OWNERSHIP_FILE = '.session-ownership.json';
7
+ const MAX_GENERATIONS = 16;
8
+ /** Canonical Fleet task identity; a task owner always has a launch. */
9
+ export const validOwnerTask = (taskId) => typeof taskId === 'string' && /^[0-9a-z]{9}[0-9a-f]{8}$/.test(taskId);
10
+ /** Absent evidence is `{}` (no file: ENOENT only). Unreadable or invalid evidence throws and is never treated as absent. */
11
+ export function readSessionOwnership(stateDir) {
12
+ let text;
13
+ try {
14
+ text = readFileSync(join(stateDir, SESSION_OWNERSHIP_FILE), 'utf8');
15
+ }
16
+ catch (error) {
17
+ if (error.code === 'ENOENT')
18
+ return {};
19
+ throw error;
20
+ }
21
+ if (text.length > 64 * 1024)
22
+ throw new Error('session ownership evidence too large');
23
+ const value = JSON.parse(text);
24
+ if (value?.schema !== 1 || !value.generations || typeof value.generations !== 'object' || Array.isArray(value.generations))
25
+ throw new Error('invalid session ownership evidence');
26
+ const result = {};
27
+ for (const [generation, owner] of Object.entries(value.generations)) {
28
+ if (!owner || !(owner.launchId === null || typeof owner.launchId === 'string')
29
+ || !(owner.taskId === null || validOwnerTask(owner.taskId)) || (owner.taskId !== null && owner.launchId === null))
30
+ throw new Error('invalid session ownership evidence');
31
+ result[generation] = { launchId: owner.launchId, taskId: owner.taskId };
32
+ }
33
+ return result;
34
+ }
35
+ /**
36
+ * Bind this session generation to its launch before any prompt can be admitted. A task member's generation names
37
+ * its task; a role without temp-supervisor metadata is explicitly standalone. Existing but unreadable metadata is
38
+ * unavailable evidence: the session refuses to start rather than guess. For a task-owned generation both the
39
+ * sidecar and the ledger event must persist, or the session start fails (no prompt is admitted without its binding).
40
+ */
41
+ export function recordSessionOwnership(conversation, sessionGeneration, stateDir) {
42
+ let owner;
43
+ if (!existsSync(join(stateDir, TEMP_SUPERVISOR_FILE)))
44
+ owner = { launchId: null, taskId: null };
45
+ else {
46
+ const metadata = readTempSupervisor(stateDir);
47
+ if (!metadata)
48
+ throw new Error('session ownership unavailable: temp supervisor metadata is unreadable');
49
+ const taskId = metadata.taskOwner?.taskId ?? null;
50
+ if (taskId !== null && !validOwnerTask(taskId))
51
+ throw new Error('session ownership unavailable: invalid task owner');
52
+ owner = { launchId: metadata.launchId, taskId };
53
+ }
54
+ const draft = { kind: 'session.ownership', sessionGeneration, payload: owner };
55
+ if (!owner.taskId) {
56
+ // A standalone role keeps the existing degrade-not-die behaviour. A sidecar that cannot be updated is left exactly as
57
+ // it was (never reset): the ledger event still binds this generation, and the observer refuses to guess from it.
58
+ try {
59
+ writeSidecar(stateDir, sessionGeneration, owner);
60
+ }
61
+ catch { /* evidence preserved; the ledger binding remains */ }
62
+ conversation.appendSafe(draft);
63
+ return;
64
+ }
65
+ writeSidecar(stateDir, sessionGeneration, owner);
66
+ conversation.append(draft);
67
+ }
68
+ /** Initializes only on proven absence. Corrupt or unreadable prior evidence throws and its bytes stay untouched. */
69
+ function writeSidecar(stateDir, sessionGeneration, owner) {
70
+ const generations = readSessionOwnership(stateDir);
71
+ delete generations[sessionGeneration];
72
+ const kept = Object.entries(generations).slice(-(MAX_GENERATIONS - 1));
73
+ replaceFileAtomically(join(stateDir, SESSION_OWNERSHIP_FILE), JSON.stringify({ schema: 1, generations: Object.fromEntries([...kept, [sessionGeneration, owner]]) }) + '\n', 0o600);
74
+ }