@ours.network/fleet 1.1.0-nightly.13 → 1.1.0-nightly.15

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.
@@ -9,9 +9,281 @@ const SANDBOX_ENV = 'OURS_FLEET_CODEX_SANDBOX';
9
9
  const REAL_CODEX_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
10
10
  const ACP_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
11
11
  const DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
12
+ /**
13
+ * Codex 0.145 can finish a tool-enabled turn without emitting the documented
14
+ * `turn/completed` notification. Keep this short: the authoritative
15
+ * `thread/read` reconciliation below, rather than elapsed time, is what makes
16
+ * inference safe.
17
+ */
18
+ const TERMINAL_RECONCILE_QUIET_MS = 2_000;
19
+ const MAX_LATE_TERMINAL_DEDUPLICATIONS = 1_024;
12
20
  const APPROVAL_POLICIES = new Set(['untrusted', 'on-request', 'never']);
13
21
  const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
14
22
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
23
+ const jsonRpcId = (value) => typeof value === 'string' || typeof value === 'number' ? value : undefined;
24
+ const stringField = (value, field) => isObject(value) && typeof value[field] === 'string' ? value[field] : undefined;
25
+ const turnKey = (threadId, turnId) => `${threadId}\0${turnId}`;
26
+ /**
27
+ * Reconcile Codex's missing terminal notification without guessing from a
28
+ * wall-clock timeout. A candidate needs the exact turn's authoritative final
29
+ * assistant item, no live items or server requests, and a quiet period. The
30
+ * proxy then asks app-server for the current thread snapshot. Only a terminal
31
+ * stored turn, or an idle thread containing that exact turn, may synthesize the
32
+ * missing notification.
33
+ *
34
+ * This lives in the authenticated bundled-Codex proxy, not the generic ACP
35
+ * session: only here are App Server thread/turn IDs and item lifecycles visible.
36
+ */
37
+ export class CodexTerminalRecovery {
38
+ options;
39
+ turns = new Map();
40
+ pendingTurnStarts = new Map();
41
+ serverRequests = new Map();
42
+ reconcileRequests = new Map();
43
+ inferredTurns = new Set();
44
+ reconcileSequence = 0;
45
+ constructor(options) {
46
+ this.options = options;
47
+ }
48
+ observeClientLine(line) {
49
+ const message = this.parse(line);
50
+ if (!message)
51
+ return;
52
+ const id = jsonRpcId(message.id);
53
+ if (message.method === 'turn/start' && id !== undefined) {
54
+ const threadId = stringField(message.params, 'threadId');
55
+ if (threadId)
56
+ this.pendingTurnStarts.set(id, threadId);
57
+ return;
58
+ }
59
+ if (id === undefined || message.method !== undefined)
60
+ return;
61
+ const key = this.serverRequests.get(id);
62
+ if (!key)
63
+ return;
64
+ this.serverRequests.delete(id);
65
+ const turn = this.turns.get(key);
66
+ turn?.openServerRequests.delete(id);
67
+ if (turn)
68
+ this.scheduleReconciliation(turn);
69
+ }
70
+ /** Return false when an internal response or late duplicate was consumed. */
71
+ observeServerLine(line) {
72
+ const message = this.parse(line);
73
+ if (!message)
74
+ return true;
75
+ const id = jsonRpcId(message.id);
76
+ if (id !== undefined && message.method === undefined) {
77
+ const reconcileKey = this.reconcileRequests.get(id);
78
+ if (reconcileKey) {
79
+ this.reconcileRequests.delete(id);
80
+ this.handleReconcileResponse(reconcileKey, message);
81
+ return false;
82
+ }
83
+ const threadId = this.pendingTurnStarts.get(id);
84
+ if (threadId) {
85
+ this.pendingTurnStarts.delete(id);
86
+ const turn = isObject(message.result) && isObject(message.result.turn)
87
+ ? message.result.turn : undefined;
88
+ const turnId = stringField(turn, 'id');
89
+ if (turn && turnId)
90
+ this.ensureTurn(threadId, turnId, turn);
91
+ }
92
+ return true;
93
+ }
94
+ if (message.method === 'turn/started') {
95
+ const threadId = stringField(message.params, 'threadId');
96
+ const turn = isObject(message.params) && isObject(message.params.turn)
97
+ ? message.params.turn : undefined;
98
+ const turnId = stringField(turn, 'id');
99
+ if (threadId && turn && turnId)
100
+ this.ensureTurn(threadId, turnId, turn);
101
+ return true;
102
+ }
103
+ if (message.method === 'turn/completed') {
104
+ const threadId = stringField(message.params, 'threadId');
105
+ const turn = isObject(message.params) && isObject(message.params.turn)
106
+ ? message.params.turn : undefined;
107
+ const turnId = stringField(turn, 'id');
108
+ if (!threadId || !turnId)
109
+ return true;
110
+ const key = turnKey(threadId, turnId);
111
+ if (this.inferredTurns.delete(key)) {
112
+ this.options.log(`suppressed late turn/completed after inferred terminal (${threadId}/${turnId})`);
113
+ return false;
114
+ }
115
+ this.finishTurn(key);
116
+ return true;
117
+ }
118
+ if (message.method === 'thread/status/changed') {
119
+ const threadId = stringField(message.params, 'threadId');
120
+ const status = isObject(message.params) && isObject(message.params.status)
121
+ ? message.params.status : undefined;
122
+ if (threadId && status?.type === 'idle') {
123
+ for (const turn of this.turns.values()) {
124
+ if (turn.threadId !== threadId)
125
+ continue;
126
+ turn.idleObserved = true;
127
+ this.scheduleReconciliation(turn);
128
+ }
129
+ }
130
+ return true;
131
+ }
132
+ const params = isObject(message.params) ? message.params : undefined;
133
+ const threadId = stringField(params, 'threadId');
134
+ const routedTurnId = stringField(params, 'turnId');
135
+ const routed = threadId && routedTurnId
136
+ ? this.turns.get(turnKey(threadId, routedTurnId)) : undefined;
137
+ if (routed && message.method === 'item/started') {
138
+ const item = isObject(params?.item) ? params.item : undefined;
139
+ const itemId = stringField(item, 'id');
140
+ if (itemId)
141
+ routed.openItems.add(itemId);
142
+ // Work after a final answer invalidates that candidate. A later exact
143
+ // final answer can establish a new one.
144
+ if (item?.type !== 'agentMessage' || item?.phase !== 'final_answer')
145
+ routed.finalAssistantItem = undefined;
146
+ this.cancelReconciliation(routed);
147
+ }
148
+ else if (routed && message.method === 'item/completed') {
149
+ const item = isObject(params?.item) ? params.item : undefined;
150
+ const itemId = stringField(item, 'id');
151
+ if (itemId)
152
+ routed.openItems.delete(itemId);
153
+ if (item?.type === 'agentMessage' && item.phase === 'final_answer'
154
+ && typeof item.text === 'string' && item.text.length > 0)
155
+ routed.finalAssistantItem = item;
156
+ this.scheduleReconciliation(routed);
157
+ }
158
+ // App-server requests (approvals, elicitation, user input) are obligations
159
+ // the client still owes. Never infer while one for this turn is unresolved.
160
+ if (routed && id !== undefined && typeof message.method === 'string') {
161
+ this.serverRequests.set(id, turnKey(routed.threadId, routed.turnId));
162
+ routed.openServerRequests.add(id);
163
+ this.cancelReconciliation(routed);
164
+ }
165
+ return true;
166
+ }
167
+ close() {
168
+ for (const turn of this.turns.values())
169
+ this.cancelReconciliation(turn);
170
+ this.turns.clear();
171
+ this.pendingTurnStarts.clear();
172
+ this.serverRequests.clear();
173
+ this.reconcileRequests.clear();
174
+ this.inferredTurns.clear();
175
+ }
176
+ parse(line) {
177
+ try {
178
+ const parsed = JSON.parse(line);
179
+ return isObject(parsed) ? parsed : undefined;
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ }
185
+ ensureTurn(threadId, turnId, turn) {
186
+ const key = turnKey(threadId, turnId);
187
+ const existing = this.turns.get(key);
188
+ if (existing) {
189
+ existing.turn = turn;
190
+ return existing;
191
+ }
192
+ const created = {
193
+ threadId, turnId, turn, openItems: new Set(), openServerRequests: new Set(),
194
+ idleObserved: false,
195
+ };
196
+ this.turns.set(key, created);
197
+ return created;
198
+ }
199
+ candidate(turn) {
200
+ return turn.finalAssistantItem !== undefined
201
+ && turn.openItems.size === 0 && turn.openServerRequests.size === 0
202
+ && turn.reconcileRequestId === undefined;
203
+ }
204
+ cancelReconciliation(turn) {
205
+ if (turn.reconcileTimer)
206
+ clearTimeout(turn.reconcileTimer);
207
+ turn.reconcileTimer = undefined;
208
+ }
209
+ scheduleReconciliation(turn) {
210
+ this.cancelReconciliation(turn);
211
+ if (!this.candidate(turn))
212
+ return;
213
+ turn.reconcileTimer = setTimeout(() => this.reconcile(turn), this.options.quietMs ?? TERMINAL_RECONCILE_QUIET_MS);
214
+ turn.reconcileTimer.unref?.();
215
+ }
216
+ reconcile(turn) {
217
+ turn.reconcileTimer = undefined;
218
+ if (!this.candidate(turn))
219
+ return;
220
+ const id = `ours-fleet-terminal-reconcile-${++this.reconcileSequence}`;
221
+ const key = turnKey(turn.threadId, turn.turnId);
222
+ turn.reconcileRequestId = id;
223
+ this.reconcileRequests.set(id, key);
224
+ this.options.sendToCodex(JSON.stringify({
225
+ id, method: 'thread/read', params: { threadId: turn.threadId, includeTurns: true },
226
+ }));
227
+ }
228
+ handleReconcileResponse(key, message) {
229
+ const tracked = this.turns.get(key);
230
+ if (!tracked)
231
+ return;
232
+ tracked.reconcileRequestId = undefined;
233
+ if (!this.candidate(tracked))
234
+ return;
235
+ const thread = isObject(message.result) && isObject(message.result.thread)
236
+ ? message.result.thread : undefined;
237
+ const turns = thread && Array.isArray(thread.turns) ? thread.turns : [];
238
+ const snapshot = turns.find(candidate => isObject(candidate) && candidate.id === tracked.turnId);
239
+ if (!snapshot) {
240
+ this.scheduleReconciliation(tracked);
241
+ return;
242
+ }
243
+ const status = typeof snapshot.status === 'string' ? snapshot.status : undefined;
244
+ const threadStatus = thread && isObject(thread.status) ? thread.status.type : undefined;
245
+ const storedTerminal = status === 'completed' || status === 'failed' || status === 'interrupted';
246
+ if (!storedTerminal && threadStatus !== 'idle' && !tracked.idleObserved) {
247
+ // A final answer can precede stop hooks or other short-lived runtime
248
+ // effects. Keep reconciling while the exact thread still reports active;
249
+ // never convert elapsed time alone into success.
250
+ this.scheduleReconciliation(tracked);
251
+ return;
252
+ }
253
+ const inferredTurn = storedTerminal ? snapshot : {
254
+ ...snapshot,
255
+ status: 'completed',
256
+ error: null,
257
+ completedAt: snapshot.completedAt ?? new Date().toISOString(),
258
+ };
259
+ this.inferredTurns.add(key);
260
+ if (this.inferredTurns.size > MAX_LATE_TERMINAL_DEDUPLICATIONS) {
261
+ const oldest = this.inferredTurns.values().next().value;
262
+ if (oldest)
263
+ this.inferredTurns.delete(oldest);
264
+ }
265
+ this.options.log(`inferred missing turn/completed after authoritative reconciliation (${tracked.threadId}/${tracked.turnId})`);
266
+ this.options.emitToClient(JSON.stringify({
267
+ method: 'turn/completed',
268
+ params: { threadId: tracked.threadId, turn: inferredTurn },
269
+ _meta: { oursFleet: { terminalSource: 'inferred_missing_notification' } },
270
+ }));
271
+ this.finishTurn(key);
272
+ }
273
+ finishTurn(key) {
274
+ const turn = this.turns.get(key);
275
+ if (!turn)
276
+ return;
277
+ this.cancelReconciliation(turn);
278
+ if (turn.reconcileRequestId !== undefined) {
279
+ this.reconcileRequests.delete(turn.reconcileRequestId);
280
+ turn.reconcileRequestId = undefined;
281
+ }
282
+ for (const id of turn.openServerRequests)
283
+ this.serverRequests.delete(id);
284
+ this.turns.delete(key);
285
+ }
286
+ }
15
287
  function sandboxMode(policy) {
16
288
  if (!isObject(policy))
17
289
  return undefined;
@@ -94,24 +366,83 @@ export function runCodexAppServerProxy() {
94
366
  const child = spawn(launch.command, launch.args, {
95
367
  env, shell: launch.shell, stdio: ['pipe', 'pipe', 'pipe'],
96
368
  });
97
- child.stdout.pipe(process.stdout);
98
- child.stderr.pipe(process.stderr);
369
+ child.stderr.pipe(process.stderr, { end: false });
99
370
  const input = createInterface({ input: process.stdin });
371
+ const output = createInterface({ input: child.stdout });
100
372
  let finished = false;
373
+ let clientBackpressured = false;
374
+ const writeToClient = (line) => {
375
+ if (finished)
376
+ return;
377
+ if (!process.stdout.write(line + '\n')) {
378
+ if (!clientBackpressured) {
379
+ clientBackpressured = true;
380
+ output.pause();
381
+ process.stdout.once('drain', () => {
382
+ clientBackpressured = false;
383
+ if (!finished)
384
+ output.resume();
385
+ });
386
+ }
387
+ }
388
+ };
389
+ let childBackpressured = false;
390
+ const pendingChildLines = [];
391
+ const writeToChild = (line) => {
392
+ if (finished || child.stdin.destroyed)
393
+ return;
394
+ if (childBackpressured) {
395
+ pendingChildLines.push(line);
396
+ return;
397
+ }
398
+ if (!child.stdin.write(line + '\n')) {
399
+ childBackpressured = true;
400
+ input.pause();
401
+ child.stdin.once('drain', () => {
402
+ childBackpressured = false;
403
+ while (!childBackpressured && pendingChildLines.length > 0) {
404
+ const pending = pendingChildLines.shift();
405
+ if (!child.stdin.write(pending + '\n'))
406
+ childBackpressured = true;
407
+ }
408
+ if (!finished && !childBackpressured)
409
+ input.resume();
410
+ });
411
+ }
412
+ };
413
+ const recovery = new CodexTerminalRecovery({
414
+ sendToCodex: writeToChild,
415
+ emitToClient: writeToClient,
416
+ log: line => process.stderr.write(`ours-fleet Codex terminal recovery: ${line}\n`),
417
+ });
101
418
  const finish = (code) => {
102
419
  if (finished)
103
420
  return;
104
421
  finished = true;
105
- process.exitCode = code;
422
+ recovery.close();
106
423
  input.close();
107
424
  process.stdin.destroy();
425
+ // stdout/stderr are often pipes under Fleet and tests. Setting exitCode
426
+ // before their pending writes flush can drop the child's last diagnostic
427
+ // or protocol line. Empty writes provide ordered flush barriers.
428
+ // Let readable `data`/`line` callbacks queued with the child's `close`
429
+ // notification run before placing the barriers.
430
+ setImmediate(() => {
431
+ process.stdout.write('', () => {
432
+ process.stderr.write('', () => { process.exitCode = code; });
433
+ });
434
+ });
108
435
  };
436
+ let spawnFailed = false;
109
437
  child.once('error', error => {
438
+ spawnFailed = true;
110
439
  process.stderr.write(`ours-fleet Codex app-server proxy: ${error.message}\n`);
111
- finish(1);
112
440
  });
113
- child.once('exit', (code, signal) => {
114
- finish(code ?? (signal ? 1 : 0));
441
+ // `exit` can precede the final stdout/stderr chunks. Wait for `close`, which
442
+ // is emitted only after the child's stdio streams have closed, so a fast
443
+ // failure cannot lose its diagnostic or the last protocol envelope.
444
+ child.once('close', (code, signal) => {
445
+ finish(spawnFailed ? 1 : code ?? (signal ? 1 : 0));
115
446
  });
116
447
  // A child can close its input before Node delivers its exit event. Its exit
117
448
  // status is the authoritative failure; do not let the resulting EPIPE race it.
@@ -119,19 +450,22 @@ export function runCodexAppServerProxy() {
119
450
  input.on('line', line => {
120
451
  if (finished)
121
452
  return;
122
- const output = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp) + '\n';
123
- if (!child.stdin.write(output)) {
124
- input.pause();
125
- child.stdin.once('drain', () => {
126
- if (!finished)
127
- input.resume();
128
- });
129
- }
453
+ const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp);
454
+ recovery.observeClientLine(rewritten);
455
+ writeToChild(rewritten);
456
+ });
457
+ output.on('line', line => {
458
+ if (!finished && recovery.observeServerLine(line))
459
+ writeToClient(line);
130
460
  });
131
461
  input.once('close', () => {
132
462
  if (!child.stdin.destroyed)
133
463
  child.stdin.end();
134
464
  });
465
+ process.stdin.once('end', () => {
466
+ if (!child.stdin.destroyed)
467
+ child.stdin.end();
468
+ });
135
469
  for (const signal of ['SIGINT', 'SIGTERM'])
136
470
  process.once(signal, () => child.kill(signal));
137
471
  }
@@ -29,6 +29,13 @@ function commandArgv(command) {
29
29
  root = root.parent;
30
30
  return root.rawArgs ?? process.argv.slice(2);
31
31
  }
32
+ function cliAnonymousOverride(argv) {
33
+ const enabled = argv.includes('--anonymous');
34
+ const disabled = argv.includes('--no-anonymous');
35
+ if (enabled && disabled)
36
+ throw new Error('--anonymous and --no-anonymous are mutually exclusive');
37
+ return enabled ? true : disabled ? false : undefined;
38
+ }
32
39
  import { getTask, getDeletingTask, activateTask, TaskStateError, } from './task-state.js';
33
40
  import { createRoomRecord, getRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
34
41
  import { createCoworkAdapter, CoworkProtocolError } from './cowork-adapter.js';
@@ -690,6 +697,8 @@ export function registerTaskCommands(parent, cOpt) {
690
697
  .option('--brief-file <path>', 'task brief from file')
691
698
  .option('--backlog', 'create in backlog (do not start immediately)')
692
699
  .option('--no-room', 'create task without a room')
700
+ .option('--anonymous', 'create an anonymous Cowork room')
701
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
693
702
  .option('--idempotency-key <key>', 'idempotency key')
694
703
  .option('--list <name>', 'task list (default: default)')
695
704
  .option('--members-file <path>', 'typed YAML member overrides')
@@ -711,6 +720,7 @@ export function registerTaskCommands(parent, cOpt) {
711
720
  actor: { kind: 'local_control', surface: 'cli' }, title: opts.title,
712
721
  brief: opts.brief, briefFile: opts.briefFile, template: opts.template,
713
722
  backlog: opts.backlog, noRoom: opts.room === false,
723
+ anonymous: cliAnonymousOverride(commandArgv(command)),
714
724
  idempotencyKey: opts.idempotencyKey, origin: { type: 'cli' },
715
725
  list: opts.list,
716
726
  members,
@@ -903,6 +913,8 @@ export function registerTaskCommands(parent, cOpt) {
903
913
  cOpt(taskCmd.command('start <id>'))
904
914
  .description('idempotently select a plan, provision, and start a task')
905
915
  .option('--template <name>', 'room template')
916
+ .option('--anonymous', 'create an anonymous Cowork room')
917
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
906
918
  .option('--members-file <path>', 'typed YAML member overrides')
907
919
  .option('--member <slot>', 'begin a typed member override block')
908
920
  .option('--agent-template <id>', 'Agent Template for current member')
@@ -920,6 +932,7 @@ export function registerTaskCommands(parent, cOpt) {
920
932
  const t = await taskRoomService(opts.configuration).startTask({
921
933
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
922
934
  template: opts.template, members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
935
+ anonymous: cliAnonymousOverride(commandArgv(command)),
923
936
  });
924
937
  if (opts.json) {
925
938
  console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
@@ -1310,6 +1323,8 @@ export function registerTaskCommands(parent, cOpt) {
1310
1323
  cOpt(taskCmd.command('work <id>'))
1311
1324
  .description('deprecated alias for task start')
1312
1325
  .option('--template <name>', 'room template')
1326
+ .option('--anonymous', 'create an anonymous Cowork room')
1327
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
1313
1328
  .option('--members-file <path>', 'typed YAML member overrides')
1314
1329
  .option('--member <slot>', 'begin a typed member override block')
1315
1330
  .option('--agent-template <id>', 'Agent Template for current member')
@@ -1329,6 +1344,7 @@ export function registerTaskCommands(parent, cOpt) {
1329
1344
  const result = await taskRoomService(opts.configuration).ensureTaskWork({
1330
1345
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id, template: opts.template,
1331
1346
  members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
1347
+ anonymous: cliAnonymousOverride(commandArgv(command)),
1332
1348
  });
1333
1349
  const t = result.task;
1334
1350
  auditTask('work', t, previous);
@@ -1414,6 +1430,8 @@ export function registerRoomCommands(parent, cOpt) {
1414
1430
  .description('create a standalone room')
1415
1431
  .requiredOption('--name <name>', 'room name')
1416
1432
  .option('--template <name>', 'room template')
1433
+ .option('--anonymous', 'create an anonymous Cowork room')
1434
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
1417
1435
  .option('--goal <text>', 'room goal')
1418
1436
  .option('--brief <text>', 'room briefing')
1419
1437
  .option('--brief-file <path>', 'room briefing from file')
@@ -1435,6 +1453,7 @@ export function registerRoomCommands(parent, cOpt) {
1435
1453
  actor: { kind: 'local_control', surface: 'cli' }, name: opts.name,
1436
1454
  template: opts.template, goal: opts.goal, brief: opts.brief, briefFile: opts.briefFile,
1437
1455
  members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
1456
+ anonymous: cliAnonymousOverride(commandArgv(command)),
1438
1457
  });
1439
1458
  if (opts.json) {
1440
1459
  console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
3
+ import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_ROOM_KEYS as TRK, } from './types.js';
4
4
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
5
5
  const CID_RE = /^[0-9a-fA-F]{64}$/;
6
6
  export class RoomsTasksConfigError extends Error {
@@ -153,6 +153,10 @@ export function validateRoomTemplatesConfig(raw, path) {
153
153
  if (tplRaw.room !== undefined) {
154
154
  if (!isPlainObject(tplRaw.room))
155
155
  throw new RoomsTasksConfigError(path, `room_templates.${name}.room: must be a mapping`);
156
+ rejectUnknown(tplRaw.room, TRK, path, `room_templates.${name}.room`);
157
+ for (const key of TRK)
158
+ if (tplRaw.room[key] !== undefined && typeof tplRaw.room[key] !== 'boolean')
159
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.room.${key}: must be a boolean`);
156
160
  room = {
157
161
  quiet_membership: tplRaw.room.quiet_membership,
158
162
  anonymous: tplRaw.room.anonymous,
@@ -33,6 +33,7 @@ export interface CoworkRoomInfo {
33
33
  identity_cid: string;
34
34
  room_name: string;
35
35
  state: 'provisioning' | 'active' | 'closing' | 'closed';
36
+ anonymous: boolean;
36
37
  seats: CoworkSeatInfo[];
37
38
  goal?: string;
38
39
  briefing?: string;
@@ -47,6 +47,11 @@ function roomState(value, operation) {
47
47
  throw new CoworkProtocolError(operation, 'room state is invalid');
48
48
  return value;
49
49
  }
50
+ function boolean(value, operation, label) {
51
+ if (typeof value !== 'boolean')
52
+ throw new CoworkProtocolError(operation, `${label} must be a boolean`);
53
+ return value;
54
+ }
50
55
  function seatState(value, operation) {
51
56
  if (value !== 'pending' && value !== 'active' && value !== 'removed')
52
57
  throw new CoworkProtocolError(operation, 'seat state is invalid');
@@ -86,6 +91,7 @@ function projectRoom(value, operation) {
86
91
  identity_cid: text(room.identity_cid, operation, 'room.identity_cid'),
87
92
  room_name: string(room.room_name, operation, 'room.room_name'),
88
93
  state: roomState(room.state, operation),
94
+ anonymous: room.anonymous === undefined ? false : boolean(room.anonymous, operation, 'room.anonymous'),
89
95
  seats: room.seats.map((seat) => projectSeat(seat, operation)),
90
96
  role_briefings: projectedBriefings,
91
97
  ...(typeof mission?.goal === 'string' ? { goal: mission.goal } : {}),
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
  import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
6
6
  import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
7
+ import { storedRoomLaunchPolicy } from './types.js';
7
8
  import { spawnTemp } from '../spawn.js';
8
9
  import { effectivePermissionMode } from '../permissions.js';
9
10
  import { selectionOrigin, summarizeResolvedLaunch, } from '../lifecycle-summary.js';
@@ -142,7 +143,7 @@ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCi
142
143
  })),
143
144
  });
144
145
  }
145
- function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
146
+ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId, anonymous = false) {
146
147
  const provenance = readProvenance(dir);
147
148
  if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
148
149
  return false;
@@ -154,6 +155,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
154
155
  && startup.room_identity_cid === roomIdentityCid
155
156
  && startup.identity_name === member.name
156
157
  && startup.role === member.coworkRole
158
+ && (startup.anonymous ?? false) === anonymous
157
159
  && sha256Text(startup.task ?? '') === taskSha
158
160
  && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
159
161
  && typeof startup.invite === 'string'
@@ -169,16 +171,17 @@ async function retainRunningLaunch(input) {
169
171
  .find(candidate => candidate.role_name === member.name);
170
172
  const dir = agentDir(member.name, true);
171
173
  const taskSha = sha256Text(task);
174
+ const anonymous = storedRoomLaunchPolicy(getRoomRecord(provision.roomId)?.room_policy).anonymous;
172
175
  if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
173
176
  || seat.launch?.state === 'failed') && existsSync(dir)) {
174
- if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
177
+ if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
175
178
  const provenance = readProvenance(dir);
176
179
  const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
177
180
  && Boolean(seat.launch.caller_role)
178
181
  && provenance?.surface === 'agent'
179
182
  && provenance.callerRole === seat.launch.caller_role
180
183
  && typeof provenance.creationActionId === 'string'
181
- && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
184
+ && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous);
182
185
  if (!adoptable)
183
186
  throw new Error(`existing launch for ${member.name} does not match its durable intent`);
184
187
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -218,7 +221,7 @@ async function retainRunningLaunch(input) {
218
221
  if (!seat.launch.launch_id || !seat.launch.action_id)
219
222
  throw new Error(`missing durable launch identity for disappeared ${member.name}`);
220
223
  const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
221
- if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
224
+ if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
222
225
  throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
223
226
  }
224
227
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -230,7 +233,7 @@ async function retainRunningLaunch(input) {
230
233
  if (!seat.launch.action_id)
231
234
  throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
232
235
  const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
233
- if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
236
+ if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
234
237
  throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
235
238
  }
236
239
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -300,7 +303,7 @@ async function launchMemberUnlocked(input) {
300
303
  } });
301
304
  }
302
305
  const supervisor = readTempSupervisor(launchedDir);
303
- if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
306
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id, startup.anonymous ?? false)) {
304
307
  throw new Error(`new launch for ${member.name} did not persist matching provenance`);
305
308
  }
306
309
  const presentation = launched.configuration
@@ -371,6 +374,10 @@ function reconcileMemberSeats(roomId, members, observed) {
371
374
  updateMemberSeats(roomId, seats);
372
375
  return { complete, seats };
373
376
  }
377
+ function assertCoworkRoomPolicy(room, expectedAnonymous) {
378
+ if ((room.anonymous ?? false) !== expectedAnonymous)
379
+ throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
380
+ }
374
381
  export async function provisionMembers(input) {
375
382
  const { cfg, cowork, roomId, taskId, template } = input;
376
383
  // Deletion-epoch pre-check; each member launch re-checks under the lock.
@@ -387,6 +394,7 @@ export async function provisionMembers(input) {
387
394
  throw new Error(`room ${roomId} has no pinned room identity CID`);
388
395
  const roomIdentityCid = existing.room_identity_cid;
389
396
  const ownerSeatCid = existing.owner_seat_cid ?? null;
397
+ const roomPolicy = storedRoomLaunchPolicy(existing.room_policy);
390
398
  const persistedNames = new Set(existing.member_seats.map(seat => seat.role_name));
391
399
  const resuming = members.length > 0
392
400
  && members.every(member => persistedNames.has(member.name));
@@ -427,6 +435,7 @@ export async function provisionMembers(input) {
427
435
  advanceSaga(roomId, 'join_role_groups', 4);
428
436
  try {
429
437
  const initialRoom = await cowork.recoverRoom(roomId);
438
+ assertCoworkRoomPolicy(initialRoom, roomPolicy.anonymous);
430
439
  reconcileMemberSeats(roomId, members, initialRoom.seats);
431
440
  for (const member of members) {
432
441
  const task = tasks.get(member.name);
@@ -463,6 +472,7 @@ export async function provisionMembers(input) {
463
472
  role: member.coworkRole,
464
473
  task,
465
474
  owner_seat_cid: ownerSeatCid,
475
+ anonymous: roomPolicy.anonymous,
466
476
  },
467
477
  });
468
478
  }
@@ -493,6 +503,7 @@ export async function provisionMembers(input) {
493
503
  let delay = policy.initialDelayMs;
494
504
  for (;;) {
495
505
  const remote = await cowork.recoverRoom(roomId);
506
+ assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
496
507
  const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
497
508
  if (reconciled.complete)
498
509
  break;
@@ -9,6 +9,7 @@ export interface CreateRoomInput {
9
9
  room_identity_cid?: string;
10
10
  task_id?: string;
11
11
  template_snapshot?: import('./types.js').TemplateSnapshot;
12
+ room_policy?: import('./types.js').RoomLaunchPolicy;
12
13
  }
13
14
  export declare function createRoomRecord(input: CreateRoomInput): RoomOrchestrationRecord;
14
15
  export declare function getRoomRecord(id: string): RoomOrchestrationRecord | undefined;