@ours.network/fleet 1.2.0-nightly.4 → 1.2.0-nightly.6

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 CHANGED
@@ -1694,3 +1694,19 @@ provisioning or automatic reverse-proxy configuration. Existing local HTTP and
1694
1694
  SSH-tunnel profiles continue to work.
1695
1695
 
1696
1696
  See [supervisor-owned ours and migration](docs/supervisor-ours.md) for the runtime contract and review build instructions.
1697
+
1698
+ ### One-server gateway profile
1699
+
1700
+ A private client profile may include `serverUrl`, for example
1701
+ `https://ours.example/base`, together with `endpoint` set to
1702
+ `https://ours.example/base/daemon`, the existing `expectedInstanceId`, and the
1703
+ absolute private `credentialPath`. Fleet preserves that daemon prefix for doctor
1704
+ and monitoring and derives `/cowork/management/rpc` from `serverUrl` for room
1705
+ management. Requests use the issued server credential, reject redirects, have
1706
+ bounded bodies/deadlines, and never replay mutations. This capability is declared
1707
+ as `cowork.http-management-v1` in the built artifact.
1708
+
1709
+ Explicit Cowork socket, config or state-directory overrides continue to select
1710
+ local Unix management. Profiles without `serverUrl` retain legacy behavior. The
1711
+ HTTP API grants operator room authority; use the installer's supported private
1712
+ or authenticated external gateway entry, and keep its backend ports private.
@@ -11,19 +11,31 @@ import { atomicPrivateWrite, binderKey } from './state.js';
11
11
  import { AgentOursRuntime } from './runtime.js';
12
12
  import { startMcpEndpoint } from './mcp-endpoint.js';
13
13
  export const privateRuntimeRoot = () => join(stateRoot(), 'private-ours');
14
+ function roomSecretPath(role) {
15
+ const startup = role.roomMemberStartup;
16
+ return join(privateRuntimeRoot(), 'room-inputs', binderKey(startup.room_identity_cid, JSON.stringify([role.name, startup.invite_id])) + '.json');
17
+ }
18
+ function matchesRoomSecret(secret, role) {
19
+ const startup = role.roomMemberStartup;
20
+ return secret.room_id === startup.room_id && secret.room_identity_cid === startup.room_identity_cid
21
+ && secret.invite_id === startup.invite_id && secret.identity_name === role.identity
22
+ && secret.role === startup.role;
23
+ }
14
24
  /** Only trusted launch orchestration may write this descriptor, never the child. */
15
25
  export function storeRoomSecret(role) {
16
26
  const startup = role.roomMemberStartup;
17
27
  if (!startup?.invite)
18
28
  return;
29
+ if (startup.identity_name !== role.identity)
30
+ throw Error('ROOM_SECRET_MISMATCH');
19
31
  const root = join(privateRuntimeRoot(), 'room-inputs');
20
32
  mkdirSync(root, { recursive: true, mode: 0o700 });
21
- const path = join(root, binderKey(startup.room_identity_cid, role.name) + '.json');
33
+ // Failed attempts retain private evidence. A new invite must never overwrite
34
+ // or consume a previous attempt's descriptor.
35
+ const path = roomSecretPath(role);
22
36
  if (existsSync(path)) {
23
37
  const old = JSON.parse(readFileSync(path, 'utf8'));
24
- if (old.room_id !== startup.room_id ||
25
- old.invite_id !== startup.invite_id ||
26
- old.identity_name !== startup.identity_name)
38
+ if (!matchesRoomSecret(old, role) || old.invite !== startup.invite)
27
39
  throw Error('ROOM_SECRET_COLLISION');
28
40
  return;
29
41
  }
@@ -144,7 +156,9 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
144
156
  let room;
145
157
  if (role.roomMemberStartup) {
146
158
  const startup = role.roomMemberStartup;
147
- const path = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
159
+ const legacyPath = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
160
+ const currentPath = roomSecretPath(role);
161
+ const path = existsSync(currentPath) ? currentPath : legacyPath;
148
162
  const cowork = createCoworkAdapter();
149
163
  room = {
150
164
  id: startup.room_id,
@@ -153,9 +167,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
153
167
  action: startup.invite_id,
154
168
  redeem: async (attached) => {
155
169
  const secret = JSON.parse(readFileSync(path, 'utf8'));
156
- if (secret.room_id !== startup.room_id ||
157
- secret.invite_id !== startup.invite_id ||
158
- secret.identity_name !== role.identity)
170
+ if (!matchesRoomSecret(secret, role))
159
171
  throw Error('ROOM_SECRET_MISMATCH');
160
172
  return attached.addContact({ invite: secret.invite });
161
173
  },
@@ -136,7 +136,7 @@ export class TaskRoomApplicationService {
136
136
  }
137
137
  catch (error) {
138
138
  if (error instanceof CoworkUnavailableError)
139
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
139
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
140
140
  // Once the durable Room exists, provisioning errors are resumable
141
141
  // saga state. Return that explicit state so the command can launch a
142
142
  // continuation and report the durable in-progress outcome.
@@ -234,17 +234,23 @@ export class TaskRoomApplicationService {
234
234
  const ready = task.state === 'active' && room?.state === 'active'
235
235
  && active === expected && launched === expected;
236
236
  const blocker = task.outcome?.summary ?? task.blocked?.reason ?? room?.saga.error;
237
- const nextAction = room?.provisioning_detail === 'waiting_owner_authorization'
238
- ? `Ensure ours-cowork 1.3.0 or newer is running and available, then run ours-fleet task start ${task.task_id}.`
239
- : room?.provisioning_detail === 'waiting_owner_invite'
240
- ? `Rotate rooms.owner.public_invite, then run ours-fleet task start ${task.task_id}.`
241
- : room?.provisioning_detail === 'owner_cid_mismatch'
242
- ? `Verify rooms.owner.expected_cid, rotate the Owner invite, then run ours-fleet task start ${task.task_id}.`
243
- : room?.provisioning_detail === 'waiting_cowork'
244
- ? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
245
- : failed
246
- ? `Correct the blocker, then run ours-fleet task start ${task.task_id}.`
247
- : undefined;
237
+ const nextAction = task.terminal_intent
238
+ ? `Complete the accepted ${task.terminal_intent.kind} operation; do not restart provisioning.`
239
+ : room?.state === 'closing' || room?.state === 'closed'
240
+ ? `Complete room cleanup; do not restart provisioning.`
241
+ : room?.provisioning_detail === 'member_failed'
242
+ ? `Inspect the failed launch, then run ours-fleet task start ${task.task_id}.`
243
+ : room?.provisioning_detail === 'waiting_owner_authorization'
244
+ ? `Ensure ours-cowork 1.3.0 or newer is running and available, then run ours-fleet task start ${task.task_id}.`
245
+ : room?.provisioning_detail === 'waiting_owner_invite'
246
+ ? `Rotate rooms.owner.public_invite, then run ours-fleet task start ${task.task_id}.`
247
+ : room?.provisioning_detail === 'owner_cid_mismatch'
248
+ ? `Verify rooms.owner.expected_cid, rotate the Owner invite, then run ours-fleet task start ${task.task_id}.`
249
+ : room?.provisioning_detail === 'waiting_cowork'
250
+ ? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
251
+ : failed
252
+ ? `Correct the blocker, then run ours-fleet task start ${task.task_id}.`
253
+ : undefined;
248
254
  return {
249
255
  kind: failed ? 'failed' : ready ? 'ready' : 'in_progress', task, room,
250
256
  launch: {
@@ -410,7 +416,9 @@ export class TaskRoomApplicationService {
410
416
  let task = readTask(input.taskId);
411
417
  let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
412
418
  const issues = [];
413
- if (task.state !== 'provisioning')
419
+ if (task.state !== 'provisioning' || task.terminal_intent
420
+ || room?.state === 'closing' || room?.state === 'closed'
421
+ || room?.provisioning_detail === 'member_failed')
414
422
  return {
415
423
  kind: 'no_op', task, room, issues,
416
424
  };
@@ -784,7 +792,7 @@ export class TaskRoomApplicationService {
784
792
  }
785
793
  catch (error) {
786
794
  if (error instanceof CoworkUnavailableError)
787
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
795
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
788
796
  const current = readTask(task.task_id);
789
797
  if (!current.room_id || !getRoomRecord(current.room_id))
790
798
  throw error;
@@ -802,7 +810,7 @@ export class TaskRoomApplicationService {
802
810
  }
803
811
  catch (error) {
804
812
  if (error instanceof CoworkUnavailableError)
805
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
813
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
806
814
  task = readTask(task.task_id);
807
815
  return { task, status: 'in_progress' };
808
816
  }
@@ -820,7 +828,7 @@ export class TaskRoomApplicationService {
820
828
  }
821
829
  catch (error) {
822
830
  if (error instanceof CoworkUnavailableError)
823
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
831
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
824
832
  task = readTask(task.task_id);
825
833
  }
826
834
  }
@@ -878,11 +886,17 @@ export class TaskRoomApplicationService {
878
886
  if (fresh.deletion?.status === 'pending')
879
887
  throw new TaskRoomApplicationError('task_deleting', `task ${task.task_id} is pending deletion`, { task: task.task_id });
880
888
  }
889
+ const requiredRoles = new Map();
890
+ if (attachOwner)
891
+ requiredRoles.set(rooms.owner.role, 1);
892
+ for (const member of launchTemplate?.members ?? [])
893
+ requiredRoles.set(member.role, (requiredRoles.get(member.role) ?? 0) + member.count);
881
894
  const created = await cowork.createRoom({
882
895
  room_name: roomName, goal: task.goal?.trim() || task.title,
883
896
  briefing: task.brief?.trim() || launchTemplate?.contract?.trim() || task.goal?.trim() || task.title,
884
897
  quiet_membership: launchTemplate?.room?.quiet_membership,
885
898
  anonymous: policy.anonymous,
899
+ activation_requirements: [...requiredRoles].map(([role, count]) => ({ role, count })),
886
900
  });
887
901
  const record = createRoomRecord({
888
902
  room_id: created.room_id, room_name: roomName, room_identity_cid: created.identity_cid,
@@ -1,10 +1,11 @@
1
1
  {
2
- "version": "1.2.0-nightly.4",
3
- "buildId": "10dccae7a337",
4
- "commit": "2192785d3823d7a2cae1f3b5c7060bd97175340e",
2
+ "version": "1.2.0-nightly.6",
3
+ "buildId": "2075277a291e",
4
+ "commit": "01660e304fff7ee37784669ec601299c92df4912",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-21T20:49:10.393Z",
6
+ "builtAt": "2026-09-23T10:49:32.469Z",
7
7
  "capabilities": [
8
+ "cowork.http-management-v1",
8
9
  "monitor.interrupt.after_tool"
9
10
  ]
10
11
  }
@@ -14,7 +14,8 @@
14
14
  */
15
15
  /** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
16
16
  export declare const CAP_MONITOR_INTERRUPT_AFTER_TOOL = "monitor.interrupt.after_tool";
17
- export declare const CAPABILITIES: readonly ["monitor.interrupt.after_tool"];
17
+ export declare const CAP_HTTP_COWORK = "cowork.http-management-v1";
18
+ export declare const CAPABILITIES: readonly ["cowork.http-management-v1", "monitor.interrupt.after_tool"];
18
19
  export type Capability = (typeof CAPABILITIES)[number];
19
20
  /** Does this build declare `token`? */
20
21
  export declare const hasCapability: (token: string) => boolean;
@@ -14,7 +14,9 @@
14
14
  */
15
15
  /** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
16
16
  export const CAP_MONITOR_INTERRUPT_AFTER_TOOL = 'monitor.interrupt.after_tool';
17
+ export const CAP_HTTP_COWORK = 'cowork.http-management-v1';
17
18
  export const CAPABILITIES = [
19
+ CAP_HTTP_COWORK,
18
20
  CAP_MONITOR_INTERRUPT_AFTER_TOOL,
19
21
  ];
20
22
  /** Does this build declare `token`? */
@@ -3,6 +3,7 @@ export interface ExplicitClientProfile {
3
3
  readonly expectedInstanceId: string;
4
4
  readonly credentialPath: string;
5
5
  readonly configPath: string;
6
+ readonly serverUrl?: string;
6
7
  }
7
8
  export declare class ClientProfileError extends Error {
8
9
  constructor(message: string);
@@ -1,3 +1,4 @@
1
+ import { normalizeDaemonEndpoint } from '@ours.network/sdk/client';
1
2
  import { lstatSync, readFileSync, statSync } from 'node:fs';
2
3
  import { homedir } from 'node:os';
3
4
  import { isAbsolute, join } from 'node:path';
@@ -97,17 +98,31 @@ export function readClientProfile(env) {
97
98
  catch {
98
99
  throw invalid(configPath, 'has an invalid endpoint');
99
100
  }
100
- if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/'
101
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password
101
102
  || url.search || url.hash)
102
- throw invalid(configPath, 'endpoint must be an HTTP or HTTPS origin');
103
+ throw invalid(configPath, 'endpoint must be an HTTP or HTTPS base URL');
103
104
  const expectedInstanceId = row.expectedInstanceId.trim();
104
105
  if (!LOWERCASE_UUID.test(expectedInstanceId))
105
106
  throw invalid(configPath, 'expectedInstanceId must be a lowercase UUID');
106
107
  const credentialPath = row.credentialPath.trim();
107
108
  if (!isAbsolute(credentialPath))
108
109
  throw invalid(configPath, 'credentialPath must be absolute');
110
+ let serverUrl;
111
+ if (Object.hasOwn(row, 'serverUrl')) {
112
+ if (typeof row.serverUrl !== 'string' || /[\s\\?#]/.test(row.serverUrl))
113
+ throw invalid(configPath, 'serverUrl must be a safe HTTP or HTTPS base URL');
114
+ try {
115
+ serverUrl = normalizeDaemonEndpoint(row.serverUrl);
116
+ }
117
+ catch {
118
+ throw invalid(configPath, 'serverUrl must be a safe HTTP or HTTPS base URL');
119
+ }
120
+ if (normalizeDaemonEndpoint(endpoint) !== serverUrl + '/daemon')
121
+ throw invalid(configPath, 'endpoint must select serverUrl/daemon');
122
+ }
109
123
  return Object.freeze({
110
- endpoint: url.origin, expectedInstanceId, credentialPath, configPath,
124
+ ...(serverUrl === undefined ? {} : { serverUrl }),
125
+ endpoint: normalizeDaemonEndpoint(endpoint), expectedInstanceId, credentialPath, configPath,
111
126
  });
112
127
  }
113
128
  export function clientProfileKey(profile) {
package/dist/docs.d.ts CHANGED
@@ -17,8 +17,8 @@ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persis
17
17
  * \`forbidden\` is the more important half. The old skills prescribed
18
18
  * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
19
19
  * default while also telling the agent to stop at a failed doctor check — and
20
- * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
21
- * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
20
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` cannot
21
+ * guarantee unattended capabilities and \`deny\` makes the shortfall
22
22
  * fatal. Following the skill produced a role the CLI then refused.
23
23
  */
24
24
  export declare const SPAWN_SKILL_CONTRACT: {
package/dist/docs.js CHANGED
@@ -1032,8 +1032,8 @@ reconcile explicitly; no chain preserves detection-only behavior.
1032
1032
  * \`forbidden\` is the more important half. The old skills prescribed
1033
1033
  * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
1034
1034
  * default while also telling the agent to stop at a failed doctor check — and
1035
- * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
1036
- * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
1035
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` cannot
1036
+ * guarantee unattended capabilities and \`deny\` makes the shortfall
1037
1037
  * fatal. Following the skill produced a role the CLI then refused.
1038
1038
  */
1039
1039
  export const SPAWN_SKILL_CONTRACT = {
@@ -1062,7 +1062,7 @@ export const SPAWN_SKILL_CONTRACT = {
1062
1062
  */
1063
1063
  forbidden: [
1064
1064
  // The contradictory blanket default both variants used to prescribe.
1065
- // `ask` grants an unattended role only `read-state`, and `deny` makes the
1065
+ // `ask` cannot guarantee unattended capabilities, and `deny` makes the
1066
1066
  // shortfall a doctor FAILURE — so the skill told you to build a role the
1067
1067
  // CLI then refused, in the same breath as telling you to trust doctor.
1068
1068
  '--approval ask --filesystem workspace --unattended deny',
package/dist/doctor.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type Exec } from './exec.js';
3
3
  import type { YamlMode } from './config-yaml.js';
4
4
  import { type FetchLike } from './monitor.js';
5
5
  import type { PrereqReport } from './harness/types.js';
6
- type DoctorDaemonClient = Pick<OursClient, 'version'>;
6
+ type DoctorDaemonClient = Pick<OursClient, 'version' | 'close'>;
7
7
  type AttachDoctorDaemon = (options: AttachOursClientOptions) => Promise<DoctorDaemonClient>;
8
8
  /** Host-level + per-harness prerequisite report with actionable messages. */
9
9
  export declare function doctor(opts?: {
package/dist/doctor.js CHANGED
@@ -1,4 +1,5 @@
1
- import { userInfo } from 'node:os';
1
+ import { join } from 'node:path';
2
+ import { userInfo, homedir } from 'node:os';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { attachOursClient, } from '@ours.network/sdk/client';
4
5
  import { realExec } from './exec.js';
@@ -272,21 +273,39 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
272
273
  }
273
274
  catch { /* state dir may not exist yet */ }
274
275
  }
276
+ let managedSelection = false;
275
277
  try {
276
- const client = await attachDaemon({
277
- env: process.env,
278
+ // Mark explicit selection before validation so a damaged managed profile does
279
+ // not produce advice to start an unrelated local daemon.
280
+ managedSelection = process.env.OURS_CONFIG !== undefined
281
+ || existsSync(join(process.env.HOME || homedir(), '.ours-client', 'profile.json'));
282
+ const selected = readClientProfile(process.env);
283
+ managedSelection = selected !== undefined;
284
+ const client = await attachDaemon(selected ? {
285
+ endpoint: selected.endpoint, expectedInstanceId: selected.expectedInstanceId,
286
+ credentialPath: selected.credentialPath, env: {}, sessionMode: 'external',
278
287
  leaseToken: `ours-fleet-doctor-${process.pid}`,
279
- clientPid: process.pid,
288
+ } : {
289
+ env: process.env, leaseToken: `ours-fleet-doctor-${process.pid}`, clientPid: process.pid,
280
290
  });
281
- const info = await client.version();
282
- if (info.name !== 'ours' || typeof info.version !== 'string')
283
- throw new Error('the selected endpoint did not return a valid ours daemon identity');
284
- checks.push({ name: 'ours daemon', ok: true, detail: `running (${info.version})` });
291
+ let daemonVersion;
292
+ try {
293
+ const info = await client.version();
294
+ if (info.name !== 'ours' || typeof info.version !== 'string')
295
+ throw new Error('the selected endpoint did not return a valid ours daemon identity');
296
+ daemonVersion = info.version;
297
+ }
298
+ finally {
299
+ await client.close();
300
+ }
301
+ checks.push({ name: 'ours daemon', ok: true, detail: `running (${daemonVersion})` });
285
302
  }
286
303
  catch (error) {
287
304
  checks.push({
288
305
  name: 'ours daemon', ok: false,
289
- detail: `not reachable through the SDK — start it with: ours-daemon start `
306
+ detail: (managedSelection
307
+ ? 'selected client profile failed — check its endpoint, instance and issued credential '
308
+ : 'not reachable through the SDK — start it with: ours-daemon start ')
290
309
  + `[${error?.message ?? String(error)}]`,
291
310
  });
292
311
  }
@@ -6,8 +6,9 @@ import type { AcpSessionTransport } from './acp-session-transport.js';
6
6
  import type { CodexAppServerSessionTransport } from './codex-session.js';
7
7
  /**
8
8
  * What an unattended role can actually do under Codex's native settings.
9
- * `on-request` and `untrusted` stop to ask, and with no console attached that
10
- * request is refused rather than answered — so the role can only read.
9
+ * `on-request` and `untrusted` can ask even for startup file reads (for example,
10
+ * shell reads or files outside the sandbox). Without a controller the request
11
+ * waits or is denied according to policy. Neither guarantees `read-state`.
11
12
  */
12
13
  export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
13
14
  /** Defense in depth for callers which launch a role after validation was bypassed. */
@@ -25,12 +25,13 @@ const CODEX_PROXY_REAL_PATH_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
25
25
  const CODEX_PROXY_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
26
26
  /**
27
27
  * What an unattended role can actually do under Codex's native settings.
28
- * `on-request` and `untrusted` stop to ask, and with no console attached that
29
- * request is refused rather than answered — so the role can only read.
28
+ * `on-request` and `untrusted` can ask even for startup file reads (for example,
29
+ * shell reads or files outside the sandbox). Without a controller the request
30
+ * waits or is denied according to policy. Neither guarantees `read-state`.
30
31
  */
31
32
  export function codexCapabilities(approval, sandbox) {
32
33
  if (approval !== 'never')
33
- return ['read-state'];
34
+ return [];
34
35
  const caps = ['read-state', 'messaging', 'monitor', 'status-commands'];
35
36
  if (sandbox !== 'read-only')
36
37
  caps.push('write-state', 'workspace-edit');
@@ -14,6 +14,8 @@ export interface OwnerChannelOptions {
14
14
  harness: string;
15
15
  config: OwnerChannelConfig;
16
16
  session: AgentSession;
17
+ /** Ordinary owner input must not cancel the initial briefing turn. */
18
+ startupPending?: () => boolean;
17
19
  stateDir: string;
18
20
  env?: Record<string, string>;
19
21
  log(line: string): void;
@@ -269,6 +271,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
269
271
  private attachmentGroups;
270
272
  private handleAttachmentGroup;
271
273
  private handle;
274
+ private ownerPromptPolicy;
272
275
  /**
273
276
  * Deterministic command path: the message never becomes an agent prompt.
274
277
  * Authorization already happened — the managed-agent relay branch and the
@@ -1123,6 +1123,13 @@ export class OwnerChannel {
1123
1123
  const retrieved = unread.length
1124
1124
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1125
1125
  : [];
1126
+ // getFiles returns daemon-local paths, which need not exist in Fleet's
1127
+ // filesystem (for example with an HTTP daemon in a container). Fetch
1128
+ // through the bound client and keep the daemon's integrity metadata for
1129
+ // admission below; never trust or remap the returned filesystem path.
1130
+ for (const file of retrieved) {
1131
+ file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1132
+ }
1126
1133
  for (const file of historyRecovered) {
1127
1134
  if (!group.recovery)
1128
1135
  throw new Error('unexpected read attachment without recovery route');
@@ -1136,8 +1143,7 @@ export class OwnerChannel {
1136
1143
  await mkdir(outbox, { recursive: true, mode: 0o700 });
1137
1144
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1138
1145
  const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
1139
- interrupt: this.options.config.interrupt,
1140
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1146
+ ...this.ownerPromptPolicy(),
1141
1147
  origin: { kind: 'owner', requestId,
1142
1148
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
1143
1149
  });
@@ -1263,8 +1269,7 @@ export class OwnerChannel {
1263
1269
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1264
1270
  try {
1265
1271
  queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
1266
- interrupt: this.options.config.interrupt,
1267
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1272
+ ...this.ownerPromptPolicy(),
1268
1273
  origin: { kind: 'owner', requestId, displayText: text },
1269
1274
  });
1270
1275
  }
@@ -1309,6 +1314,14 @@ export class OwnerChannel {
1309
1314
  this.completionTasks.add(task);
1310
1315
  return true;
1311
1316
  }
1317
+ ownerPromptPolicy() {
1318
+ if (this.options.startupPending?.())
1319
+ return { interrupt: false, steer: true };
1320
+ return {
1321
+ interrupt: this.options.config.interrupt,
1322
+ ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1323
+ };
1324
+ }
1312
1325
  /**
1313
1326
  * Deterministic command path: the message never becomes an agent prompt.
1314
1327
  * Authorization already happened — the managed-agent relay branch and the
@@ -1762,6 +1775,13 @@ export class OwnerChannel {
1762
1775
  const retrieved = unread.length
1763
1776
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1764
1777
  : [];
1778
+ // getFiles returns daemon-local paths, which need not exist in Fleet's
1779
+ // filesystem (for example with an HTTP daemon in a container). Fetch
1780
+ // through the bound client and keep the daemon's integrity metadata for
1781
+ // admission below; never trust or remap the returned filesystem path.
1782
+ for (const file of retrieved) {
1783
+ file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1784
+ }
1765
1785
  for (const file of historyRecovered) {
1766
1786
  if (!group.recovery)
1767
1787
  throw new Error('unexpected read attachment without recovery route');
@@ -1152,7 +1152,7 @@ export function registerTaskCommands(parent, cOpt) {
1152
1152
  const app = taskRoomService(opts.configuration);
1153
1153
  for (;;) {
1154
1154
  const before = app.taskProvisioningOutcome(id);
1155
- if (before.kind !== 'in_progress') {
1155
+ if (before.kind !== 'in_progress' || before.next_action) {
1156
1156
  await presentDetachedProvisioningOutcome(before);
1157
1157
  return;
1158
1158
  }
@@ -2,6 +2,7 @@ import { withFileLock } from '../atomic-file.js';
2
2
  import { type TempLifecycleDeps } from '../temp-lifecycle.js';
3
3
  import { type CoworkAdapter } from './cowork-adapter.js';
4
4
  import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
5
+ export declare function roomCloseLockPath(roomId: string): string;
5
6
  export interface RoomCloseDeps {
6
7
  inspectMember?(seat: RoomMemberSeat): Promise<{
7
8
  launchId: string;
@@ -4,13 +4,14 @@ import { randomUUID } from 'node:crypto';
4
4
  import { attachOursClient } from '@ours.network/sdk/client';
5
5
  import { withFileLock } from '../atomic-file.js';
6
6
  import { agentDir, stateRoot } from '../paths.js';
7
+ import { readClientProfile } from '../client-profile.js';
7
8
  import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
8
9
  import { CoworkProtocolError } from './cowork-adapter.js';
9
10
  import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
10
11
  const CLOSE_LOCK_STALE_MS = 5 * 60_000;
11
12
  const STOP_POLLS = 50;
12
13
  const STOP_POLL_MS = 100;
13
- function roomCloseLockPath(roomId) {
14
+ export function roomCloseLockPath(roomId) {
14
15
  return join(stateRoot(), 'locks', 'room-close', encodeURIComponent(roomId));
15
16
  }
16
17
  function errorText(error) {
@@ -54,16 +55,28 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
54
55
  throw new Error(`temporary role '${role}' did not reach proven stopped liveness`);
55
56
  }
56
57
  async function withIdentityClient(work) {
57
- const client = await attachOursClient({
58
+ const profile = readClientProfile(process.env);
59
+ const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
60
+ const client = await attachOursClient(profile ? {
61
+ endpoint: profile.endpoint,
62
+ expectedInstanceId: profile.expectedInstanceId,
63
+ credentialPath: profile.credentialPath,
64
+ sessionMode: 'external', env: {}, leaseToken,
65
+ } : {
58
66
  env: process.env,
59
- leaseToken: `ours-fleet-room-close-${process.pid}-${randomUUID()}`,
67
+ leaseToken,
60
68
  clientPid: process.pid,
61
69
  });
62
70
  try {
63
71
  return await work(client);
64
72
  }
65
73
  finally {
66
- await client.releaseLease().catch(() => { });
74
+ try {
75
+ await client.releaseLease().catch(() => { });
76
+ }
77
+ finally {
78
+ await client.close();
79
+ }
67
80
  }
68
81
  }
69
82
  function listedIdentity(rows, name) {
@@ -106,11 +119,36 @@ export async function removeExactMemberIdentity(seat) {
106
119
  }
107
120
  });
108
121
  }
122
+ async function assertMemberIdentityAbsent(seat) {
123
+ await withIdentityClient(async (client) => {
124
+ const rows = await client.listIdentities();
125
+ if (rows.some(row => row.name === seat.role_name ||
126
+ (seat.identity_cid && 'cid' in row && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
127
+ throw new Error(`room member '${seat.role_name}' identity absence is not proven; refusing retirement`);
128
+ }
129
+ });
130
+ }
109
131
  async function retireMember(roomId, seat, deps) {
110
132
  let room = getRoomRecord(roomId);
111
133
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
112
134
  let retirement = current.retirement;
135
+ if (retirement?.phase === 'identity_absent') {
136
+ if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
137
+ if (existsSync(agentDir(current.role_name, true)))
138
+ throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
139
+ await assertMemberIdentityAbsent(current);
140
+ }
141
+ return;
142
+ }
113
143
  if (!retirement) {
144
+ if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
145
+ // A failure before applyRole (for example invite-secret validation) has
146
+ // no supervisor to stop or archive. Settle only proven absence; this
147
+ // path never removes an identity based on missing local evidence.
148
+ await assertMemberIdentityAbsent(current);
149
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
150
+ return;
151
+ }
114
152
  if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
115
153
  if (existsSync(agentDir(current.role_name, true))) {
116
154
  throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
@@ -159,8 +197,6 @@ export async function closeManagedRoom(input) {
159
197
  try {
160
198
  if (room.close?.phase === 'retire_members') {
161
199
  for (const seat of room.member_seats) {
162
- if (seat.retirement?.phase === 'identity_absent')
163
- continue;
164
200
  await retireMember(input.roomId, seat, deps);
165
201
  }
166
202
  room = advanceRoomClose(input.roomId, 'close_cowork');
@@ -58,6 +58,10 @@ export interface CoworkAdapter {
58
58
  briefing: string;
59
59
  quiet_membership?: boolean;
60
60
  anonymous?: boolean;
61
+ activation_requirements?: Array<{
62
+ role: string;
63
+ count: number;
64
+ }>;
61
65
  }): Promise<CoworkRoomCreateResult>;
62
66
  acceptInvite(roomId: string, invite: string, opts: {
63
67
  role: string;
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { createConnection } from 'node:net';
11
+ import { readClientProfile } from '../client-profile.js';
12
+ import { coworkHttpCall } from './cowork-http.js';
11
13
  const RPC_VERSION = 1;
12
14
  const DEFAULT_TIMEOUT_MS = 30_000;
13
15
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
@@ -275,10 +277,15 @@ function rpcCall(socketPath, method, params, timeoutMs, connect) {
275
277
  });
276
278
  }
277
279
  export function createCoworkAdapter(options = {}) {
278
- const socketPath = resolveCoworkSocketPath(options);
280
+ const env = options.env ?? process.env;
281
+ const explicitLocal = options.socketPath !== undefined || options.configPath !== undefined
282
+ || env.OURS_COWORK_CONFIG !== undefined || !!env.OURS_COWORK_STATE_DIR;
283
+ const profile = explicitLocal ? undefined : readClientProfile({ ...env, HOME: options.home ?? env.HOME });
284
+ const remote = profile?.serverUrl ? profile : undefined;
285
+ const socketPath = remote ? undefined : resolveCoworkSocketPath(options);
279
286
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
280
287
  const connect = options.connect ?? ((path) => createConnection(path));
281
- const call = (method, params) => rpcCall(socketPath, method, params, timeoutMs, connect);
288
+ const call = (method, params) => remote ? coworkHttpCall(remote, method, params, timeoutMs) : rpcCall(socketPath, method, params, timeoutMs, connect);
282
289
  return {
283
290
  async available() {
284
291
  try {
@@ -296,6 +303,7 @@ export function createCoworkAdapter(options = {}) {
296
303
  briefing: opts.briefing,
297
304
  ...(opts.quiet_membership === undefined ? {} : { quiet_membership: opts.quiet_membership }),
298
305
  ...(opts.anonymous === undefined ? {} : { anonymous: opts.anonymous }),
306
+ ...(opts.activation_requirements === undefined ? {} : { activation_requirements: opts.activation_requirements }),
299
307
  }), 'room.create');
300
308
  if (!result.identity_cid)
301
309
  throw new CoworkProtocolError('room.create', 'created room did not establish an identity CID');
@@ -0,0 +1,3 @@
1
+ import type { ExplicitClientProfile } from '../client-profile.js';
2
+ /** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
3
+ export declare function coworkHttpCall(profile: ExplicitClientProfile, method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown>;
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { attachOursClient } from '@ours.network/sdk/client';
3
+ import { readPrivateFile } from '@ours.network/sdk/connector';
4
+ import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
5
+ /** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
6
+ export async function coworkHttpCall(profile, method, params, timeoutMs) {
7
+ const id = randomUUID();
8
+ const signal = AbortSignal.timeout(timeoutMs);
9
+ const body = JSON.stringify({ version: 1, id, method, params });
10
+ if (Buffer.byteLength(body) > 1024 * 1024)
11
+ throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
12
+ try {
13
+ const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
14
+ credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
15
+ await client.close();
16
+ const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
17
+ const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
18
+ method: 'POST', redirect: 'error', signal,
19
+ headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
20
+ body,
21
+ });
22
+ if (response.status === 401 || response.status === 403) {
23
+ await response.body?.cancel();
24
+ throw new CoworkProtocolError(method, 'server credential was rejected', 'unauthorized');
25
+ }
26
+ const reader = response.body?.getReader();
27
+ if (!reader)
28
+ throw new CoworkProtocolError(method, 'empty HTTP response');
29
+ const chunks = [];
30
+ let size = 0;
31
+ try {
32
+ for (;;) {
33
+ const { done, value } = await reader.read();
34
+ if (done)
35
+ break;
36
+ size += value.length;
37
+ if (size > 4 * 1024 * 1024)
38
+ throw new CoworkProtocolError(method, 'response exceeded 4 MiB');
39
+ chunks.push(value);
40
+ }
41
+ }
42
+ finally {
43
+ await reader.cancel();
44
+ }
45
+ let rpc;
46
+ try {
47
+ rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
48
+ }
49
+ catch {
50
+ throw new CoworkProtocolError(method, 'server returned malformed JSON');
51
+ }
52
+ if (!rpc || Array.isArray(rpc) || rpc.version !== 1 || rpc.id !== id
53
+ || Object.hasOwn(rpc, 'result') === Object.hasOwn(rpc, 'error'))
54
+ throw new CoworkProtocolError(method, 'server returned an invalid RPC response');
55
+ if (Object.hasOwn(rpc, 'error')) {
56
+ const error = rpc.error;
57
+ if (!error || typeof error.code !== 'string' || typeof error.message !== 'string')
58
+ throw new CoworkProtocolError(method, 'server returned an invalid RPC error');
59
+ throw new CoworkProtocolError(method, error.message, error.code);
60
+ }
61
+ if (!response.ok)
62
+ throw new CoworkProtocolError(method, `management answered HTTP ${response.status}`);
63
+ return rpc.result;
64
+ }
65
+ catch (error) {
66
+ if (error instanceof CoworkProtocolError)
67
+ throw error;
68
+ throw new CoworkUnavailableError('Cowork HTTP management failed; check the selected server and credential', { cause: error });
69
+ }
70
+ }
@@ -21,6 +21,17 @@ export interface StartupWaitPolicy {
21
21
  sleep(ms: number): Promise<void>;
22
22
  }
23
23
  export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
24
+ /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
25
+ export declare function reconcileExistingTaskMembers(input: {
26
+ taskId: string;
27
+ checkOnly?: boolean;
28
+ cowork: Pick<CoworkAdapter, 'getRoom'>;
29
+ expectedLaunches: ReadonlyArray<{
30
+ role_name: string;
31
+ launch_id: string;
32
+ identity_cid: string;
33
+ }>;
34
+ }): Promise<RoomOrchestrationRecord>;
24
35
  export declare function cleanupMembers(input: {
25
36
  roomId: string;
26
37
  taskId?: string;
@@ -14,7 +14,7 @@ import { readLaunchSnapshot, redactLaunchDefinition } from './launch-snapshot.js
14
14
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
15
15
  import { controlRequest } from '../session/control.js';
16
16
  import { SessionControlError } from '../session/types.js';
17
- import { closeManagedRoom } from './close.js';
17
+ import { closeManagedRoom, roomCloseLockPath } from './close.js';
18
18
  import { withFileLock } from '../atomic-file.js';
19
19
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
20
20
  import { TaskStateError, taskDeletionState } from './task-state.js';
@@ -244,12 +244,16 @@ async function retainRunningLaunch(input) {
244
244
  */
245
245
  async function launchMember(input) {
246
246
  const taskId = input.provision.taskId;
247
- if (!taskId)
247
+ const launch = () => withFileLock(roomCloseLockPath(input.provision.roomId), () => {
248
+ assertProvisioningOpen(input.provision);
248
249
  return launchMemberUnlocked(input);
250
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
251
+ if (!taskId)
252
+ return launch();
249
253
  return withFileLock(taskOperationLockPath(taskId), () => {
250
254
  if (taskDeletionState(taskId) !== 'none')
251
255
  throw new Error(`task ${taskId} is pending deletion; aborting member launch for ${input.member.name}`);
252
- return launchMemberUnlocked(input);
256
+ return launch();
253
257
  }, {}, TASK_OPERATION_LOCK_STALE_MS);
254
258
  }
255
259
  async function launchMemberUnlocked(input) {
@@ -371,8 +375,16 @@ function assertCoworkRoomPolicy(room, expectedAnonymous) {
371
375
  if ((room.anonymous ?? false) !== expectedAnonymous)
372
376
  throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
373
377
  }
378
+ function assertProvisioningOpen(input) {
379
+ if (input.taskId && getTask(input.taskId).terminal_intent)
380
+ throw new Error(`task ${input.taskId} has an accepted terminal intent; refusing to provision members`);
381
+ const room = getRoomRecord(input.roomId);
382
+ if (room?.state === 'closing' || room?.state === 'closed')
383
+ throw new Error(`room ${input.roomId} is ${room.state}; refusing to provision members`);
384
+ }
374
385
  export async function provisionMembers(input) {
375
386
  const { cfg, cowork, roomId, taskId, template } = input;
387
+ assertProvisioningOpen(input);
376
388
  // Deletion-epoch pre-check; each member launch re-checks under the lock.
377
389
  if (taskId && taskDeletionState(taskId) !== 'none')
378
390
  throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
@@ -446,12 +458,25 @@ export async function provisionMembers(input) {
446
458
  provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
447
459
  }))
448
460
  continue;
461
+ // A previous failed launch keeps its invite pointer durably. Never
462
+ // overwrite that requirement until the supported revoke has succeeded;
463
+ // a transport failure must fence subsequent invite issuance too.
464
+ if (currentSeat.invite_id) {
465
+ const observed = await cowork.getRoom(roomId);
466
+ if (!observed || observed.identity_cid !== roomIdentityCid)
467
+ throw new Error('cannot verify room before failed-attempt invite cleanup');
468
+ if (observed.seats.some(seat => seat.invite_id === currentSeat.invite_id
469
+ && seat.seat_state !== 'removed'))
470
+ throw new Error('failed-attempt invite has an admitted seat; reconcile before replacing the member');
471
+ await cowork.revokeInvite(roomId, currentSeat.invite_id);
472
+ }
449
473
  const issued = await cowork.issueInvite(roomId, {
450
474
  mode: 'one_time', role: member.coworkRole, min_accepts: 1,
451
475
  });
452
- const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
453
- updateMemberSeats(roomId, seats);
454
476
  try {
477
+ assertProvisioningOpen(input);
478
+ const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
479
+ updateMemberSeats(roomId, seats);
455
480
  await launchMember({
456
481
  provision: input,
457
482
  member,
@@ -470,7 +495,12 @@ export async function provisionMembers(input) {
470
495
  });
471
496
  }
472
497
  catch (error) {
473
- await cowork.revokeInvite(roomId, issued.invite_id).catch(() => { });
498
+ try {
499
+ await cowork.revokeInvite(roomId, issued.invite_id);
500
+ }
501
+ catch (cleanupError) {
502
+ throw new AggregateError([error, cleanupError], 'member launch failed and invite cleanup is unresolved; retry must revoke the retained requirement first');
503
+ }
474
504
  throw error;
475
505
  }
476
506
  }
@@ -498,7 +528,7 @@ export async function provisionMembers(input) {
498
528
  const remote = await cowork.recoverRoom(roomId);
499
529
  assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
500
530
  const reconciled = reconcileMemberSeats(roomId, members, remote.seats, ownerSeatCid ?? undefined);
501
- if (reconciled.complete)
531
+ if (reconciled.complete && remote.state === 'active')
502
532
  break;
503
533
  if (policy.now() >= deadline) {
504
534
  advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
@@ -538,6 +568,76 @@ export async function provisionMembers(input) {
538
568
  activateTask(taskId);
539
569
  return record;
540
570
  }
571
+ /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
572
+ export async function reconcileExistingTaskMembers(input) {
573
+ return withFileLock(taskOperationLockPath(input.taskId), async () => {
574
+ const task = getTask(input.taskId);
575
+ if (!['provisioning', 'active'].includes(task.state) || task.terminal_intent || taskDeletionState(input.taskId) !== 'none'
576
+ || !task.room_id)
577
+ throw new Error('task is not open for existing-member reconciliation');
578
+ const roomId = task.room_id;
579
+ return withFileLock(roomCloseLockPath(roomId), async () => {
580
+ const current = getRoomRecord(roomId);
581
+ if (!current || !['provisioning', 'active'].includes(current.state) || current.close
582
+ || current.task_id !== input.taskId || !current.room_identity_cid
583
+ || task.room_identity_cid !== current.room_identity_cid)
584
+ throw new Error('room is not open for existing-member reconciliation');
585
+ const names = new Set(input.expectedLaunches.map(seat => seat.role_name));
586
+ if (!names.size || names.size !== input.expectedLaunches.length
587
+ || current.member_seats.length !== names.size
588
+ || current.member_seats.some(seat => !names.has(seat.role_name)))
589
+ throw new Error('expected launch roster does not match the persisted room');
590
+ const remote = await input.cowork.getRoom(roomId);
591
+ if (!remote || remote.room_id !== roomId || remote.identity_cid !== current.room_identity_cid
592
+ || remote.state !== 'active')
593
+ throw new Error('Cowork room is not the exact active room');
594
+ assertCoworkRoomPolicy(remote, storedRoomLaunchPolicy(current.room_policy).anonymous);
595
+ if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid === current.owner_seat_cid && seat.seat_state === 'active'))
596
+ throw new Error('expected Owner seat is not active');
597
+ const seats = [];
598
+ for (const seat of current.member_seats) {
599
+ const expected = input.expectedLaunches.find(item => item.role_name === seat.role_name);
600
+ const launch = seat.launch;
601
+ if (seat.seat_state === 'removed' || !seat.invite_id || launch?.state !== 'launched'
602
+ || launch.launch_id !== expected.launch_id || !launch.action_id || !launch.mission_sha256
603
+ || (seat.identity_cid && seat.identity_cid !== expected.identity_cid))
604
+ throw new Error('persisted member launch does not match reconciliation evidence');
605
+ const dir = agentDir(seat.role_name, true);
606
+ if (!launchMatches(dir, { name: seat.role_name, coworkRole: seat.cowork_role }, launch.action_id, launch.mission_sha256, roomId, current.room_identity_cid, seat.invite_id, storedRoomLaunchPolicy(current.room_policy).anonymous))
607
+ throw new Error('member startup provenance mismatch');
608
+ const supervisor = readTempSupervisor(dir);
609
+ if (!supervisor || supervisor.role !== seat.role_name || supervisor.launchId !== expected.launch_id
610
+ || await tempSupervisorLiveness(dir) !== 'running')
611
+ throw new Error('expected member supervisor is not running');
612
+ const ready = readRoomReadiness(current.room_identity_cid, seat.role_name);
613
+ const matches = remote.seats.filter(item => item.display_name === seat.role_name
614
+ && item.seat_state !== 'removed');
615
+ const found = matches[0];
616
+ if (!ready || ready.room !== roomId || ready.invite !== seat.invite_id
617
+ || ready.cid !== expected.identity_cid || matches.length !== 1 || !found
618
+ || found.identity_cid !== expected.identity_cid || found.role !== seat.cowork_role
619
+ || found.seat_state !== 'active' || found.invite_id !== seat.invite_id)
620
+ throw new Error('member readiness or authenticated Cowork seat mismatch');
621
+ seats.push({ ...seat, identity_cid: expected.identity_cid, seat_state: 'active' });
622
+ }
623
+ if (input.checkOnly)
624
+ return current;
625
+ // No mutation until the complete roster has passed; these writes are replayable
626
+ // under the same task/room lifecycle locks if interrupted between files.
627
+ updateMemberSeats(roomId, seats);
628
+ updateTaskMembers(input.taskId, seats.map(seat => ({ name: seat.role_name,
629
+ identity_cid: seat.identity_cid, slot: seat.slot, cowork_role: seat.cowork_role })));
630
+ if (getTask(input.taskId).blocked)
631
+ unblockTask(input.taskId);
632
+ if (current.state !== 'active')
633
+ advanceSaga(roomId, 'activate', 6);
634
+ const activated = current.state === 'active' ? getRoomRecord(roomId) : activateRoom(roomId);
635
+ if (task.state !== 'active')
636
+ activateTask(input.taskId);
637
+ return activated;
638
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
639
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
640
+ }
541
641
  export async function cleanupMembers(input) {
542
642
  const room = getRoomRecord(input.roomId);
543
643
  if (!room)
@@ -112,6 +112,8 @@ export function setOwnerSeat(id, ownerSeatCid, inviteFingerprint) {
112
112
  }
113
113
  export function updateMemberSeats(id, seats) {
114
114
  const r = readRoom(id);
115
+ if (r.state === 'closing' || r.state === 'closed')
116
+ throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
115
117
  r.member_seats = seats;
116
118
  writeRoom(r);
117
119
  return r;
@@ -135,6 +137,8 @@ const LAUNCH_ORDER = [
135
137
  ];
136
138
  export function updateMemberStartup(id, roleName, update) {
137
139
  const r = readRoom(id);
140
+ if (r.state === 'closing' || r.state === 'closed')
141
+ throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
138
142
  const seat = r.member_seats.find(candidate => candidate.role_name === roleName);
139
143
  if (!seat)
140
144
  throw new RoomStateError(`room ${id} has no recorded member ${roleName}`);
@@ -151,6 +155,8 @@ export function updateMemberStartup(id, roleName, update) {
151
155
  }
152
156
  export function activateRoom(id) {
153
157
  const r = readRoom(id);
158
+ if (r.state === 'closing' || r.state === 'closed')
159
+ throw new RoomStateError(`room ${id} is ${r.state}; activation is fenced`);
154
160
  r.state = 'active';
155
161
  r.saga = { phase: 'completed', step_index: 0 };
156
162
  delete r.provisioning_detail;
package/dist/runner.js CHANGED
@@ -662,6 +662,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
662
662
  harness: role.harness,
663
663
  config: role.owner_channel,
664
664
  session: arbiter,
665
+ startupPending: () => !sessionStartupComplete,
665
666
  stateDir: dir,
666
667
  env: role.env,
667
668
  log: deps.log,
@@ -756,6 +757,26 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
756
757
  // interruption to steering until this startup turn reaches a terminal
757
758
  // success, so there is neither a deaf gap nor a boot-cancellation loop.
758
759
  monitorLoop = monitor?.run(pid);
760
+ // Owner traffic and agent replies must be observed during a long first
761
+ // turn. Ordinary owner input steers until startup has proved successful.
762
+ if (ownerChannel) {
763
+ try {
764
+ await ownerChannel.start();
765
+ control.setOwnerChannel(ownerChannel);
766
+ }
767
+ catch (error) {
768
+ monitor?.stop();
769
+ if (monitorLoop)
770
+ await monitorLoop;
771
+ await control.close();
772
+ await ownerChannel.close().catch(() => undefined);
773
+ ownerBinder?.release();
774
+ await agentSession.close();
775
+ unsubscribeRecovery?.();
776
+ throw new Error(`[${name}] owner channel failed to start: `
777
+ + `${error?.message ?? String(error)}`);
778
+ }
779
+ }
759
780
  const started = await starting;
760
781
  // A temporary role's first turn can be the active turn when an ours wake
761
782
  // needs immediate attention. A typed console/monitor cancellation ends
@@ -768,6 +789,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
768
789
  if (!started.succeeded && !interruptedForWake) {
769
790
  monitor?.stop();
770
791
  await control.close();
792
+ await ownerChannel?.close().catch(() => undefined);
771
793
  ownerBinder?.release();
772
794
  await agentSession.close();
773
795
  unsubscribeRecovery?.();
@@ -795,26 +817,6 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
795
817
  deps.log(`[${name}] ${sessionLabel} startup prompt cancelled by ${started.cancellationSource}; `
796
818
  + 'keeping temporary supervisor alive');
797
819
  sessionStartupComplete = true;
798
- if (ownerChannel) {
799
- try {
800
- await ownerChannel.start();
801
- }
802
- catch (error) {
803
- monitor?.stop();
804
- if (monitorLoop)
805
- await monitorLoop;
806
- await ownerChannel.close().catch(() => undefined);
807
- await control.close();
808
- ownerBinder?.release();
809
- await agentSession.close();
810
- unsubscribeRecovery?.();
811
- throw new Error(`[${name}] owner channel failed to start: `
812
- + `${error?.message ?? String(error)}`);
813
- }
814
- }
815
- if (ownerChannel) {
816
- control.setOwnerChannel(ownerChannel);
817
- }
818
820
  reloadLoopConfig = async () => {
819
821
  const nextRole = findRole(loadConfig(configPath), name);
820
822
  const definitions = nextRole.loops ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.4",
3
+ "version": "1.2.0-nightly.6",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -41,9 +41,9 @@
41
41
  "@agentclientprotocol/sdk": "^1.3.0",
42
42
  "@fastify/static": "^10.1.2",
43
43
  "@fastify/websocket": "^11.2.0",
44
- "@ours.network/cli": "2.8.1-nightly.8",
45
- "@ours.network/mcp": "1.2.0-nightly.6",
46
- "@ours.network/sdk": "3.8.1-nightly.10",
44
+ "@ours.network/cli": "2.8.1-nightly.9",
45
+ "@ours.network/mcp": "1.2.0-nightly.7",
46
+ "@ours.network/sdk": "3.8.1-nightly.11",
47
47
  "commander": "^12.1.0",
48
48
  "fastify": "^5.4.0",
49
49
  "react": "^19.1.1",
@@ -57,7 +57,7 @@
57
57
  "@agentclientprotocol/codex-acp": "1.10.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@ours.network/daemon": "3.8.1-nightly.2",
60
+ "@ours.network/daemon": "3.8.1-nightly.3",
61
61
  "@playwright/test": "^1.54.1",
62
62
  "@types/node": "^20.14.0",
63
63
  "@types/react": "^19.1.9",