@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.
- package/README.md +41 -2
- package/dist/application/conversation-history.d.ts +8 -0
- package/dist/application/conversation-history.js +37 -2
- package/dist/application/task-room-service.d.ts +7 -0
- package/dist/application/task-room-service.js +19 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/doctor.js +17 -4
- package/dist/notifications/fleet-producer.d.ts +0 -1
- package/dist/notifications/fleet-producer.js +65 -47
- package/dist/notifications/mission-ownership.d.ts +31 -0
- package/dist/notifications/mission-ownership.js +148 -0
- package/dist/notifications/outbox.d.ts +10 -0
- package/dist/notifications/outbox.js +28 -2
- package/dist/notifications/task-producer.d.ts +11 -1
- package/dist/notifications/task-producer.js +34 -5
- package/dist/rooms-tasks/layout-service.d.ts +2 -0
- package/dist/rooms-tasks/layout-service.js +7 -2
- package/dist/rooms-tasks/layout.d.ts +5 -1
- package/dist/rooms-tasks/layout.js +7 -1
- package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
- package/dist/rooms-tasks/notification-manifest.js +65 -0
- package/dist/rooms-tasks/task-review.d.ts +2 -0
- package/dist/rooms-tasks/task-review.js +7 -5
- package/dist/rooms-tasks/task-state.d.ts +35 -0
- package/dist/rooms-tasks/task-state.js +151 -17
- package/dist/rooms-tasks/types.d.ts +10 -0
- package/dist/runtime-security.d.ts +1 -0
- package/dist/runtime-security.js +6 -0
- package/dist/security-versions.d.ts +20 -0
- package/dist/security-versions.js +70 -0
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +58 -1
- package/dist/session/codex-app-server.js +22 -1
- package/dist/session/conversation-types.d.ts +8 -2
- package/dist/session/session-ownership.d.ts +18 -0
- package/dist/session/session-ownership.js +74 -0
- package/dist/setup-tunnel-preflight.js +10 -3
- package/dist/supervisor/manager.js +9 -2
- package/dist/web/resource-views.d.ts +2 -0
- package/dist/web/resource-views.js +4 -2
- package/dist/web/runtime.js +1 -1
- package/dist/web/server.js +29 -8
- package/dist/web/workspace-tunnel.d.ts +2 -1
- package/dist/web/workspace-tunnel.js +29 -3
- 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
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
pending
|
|
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()
|
|
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
|
|
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
|
-
//
|
|
971
|
-
//
|
|
972
|
-
|
|
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
|
+
}
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -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
|
package/dist/session/acp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|