@ours.network/fleet 1.1.5 → 1.2.0-nightly.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +163 -21
  2. package/dist/agent-ours/bridge.d.ts +3 -0
  3. package/dist/agent-ours/bridge.js +178 -0
  4. package/dist/agent-ours/controller.d.ts +14 -0
  5. package/dist/agent-ours/controller.js +60 -0
  6. package/dist/agent-ours/file-rpc.d.ts +23 -0
  7. package/dist/agent-ours/file-rpc.js +123 -0
  8. package/dist/agent-ours/harness.d.ts +14 -0
  9. package/dist/agent-ours/harness.js +64 -0
  10. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  11. package/dist/agent-ours/mcp-endpoint.js +111 -0
  12. package/dist/agent-ours/runtime.d.ts +53 -0
  13. package/dist/agent-ours/runtime.js +247 -0
  14. package/dist/agent-ours/service.d.ts +23 -0
  15. package/dist/agent-ours/service.js +364 -0
  16. package/dist/agent-ours/state.d.ts +45 -0
  17. package/dist/agent-ours/state.js +95 -0
  18. package/dist/agent-ours/wire.d.ts +14 -0
  19. package/dist/agent-ours/wire.js +55 -0
  20. package/dist/agent-recovery-gate.js +1 -1
  21. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  22. package/dist/application/legacy-supervisor-identity.js +43 -0
  23. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  24. package/dist/application/supervisor-ours-tools.js +121 -0
  25. package/dist/application/task-room-service.js +55 -19
  26. package/dist/briefing.js +10 -59
  27. package/dist/build-info.json +6 -5
  28. package/dist/capabilities.d.ts +2 -1
  29. package/dist/capabilities.js +2 -0
  30. package/dist/cli.js +75 -8
  31. package/dist/client-profile.d.ts +18 -0
  32. package/dist/client-profile.js +130 -0
  33. package/dist/config.d.ts +1 -0
  34. package/dist/creation.d.ts +1 -1
  35. package/dist/creation.js +13 -21
  36. package/dist/daemon-recovery.d.ts +2 -0
  37. package/dist/daemon-recovery.js +53 -2
  38. package/dist/docs.d.ts +3 -3
  39. package/dist/docs.js +32 -23
  40. package/dist/doctor.d.ts +1 -1
  41. package/dist/doctor.js +70 -14
  42. package/dist/fleet-command-audit.js +3 -2
  43. package/dist/harness/agent-session.d.ts +2 -0
  44. package/dist/harness/claude-code-session.js +4 -2
  45. package/dist/harness/codex-session.js +9 -2
  46. package/dist/harness/codex.d.ts +3 -2
  47. package/dist/harness/codex.js +8 -3
  48. package/dist/harness/hermes-session.js +7 -1
  49. package/dist/init-wizard.d.ts +11 -6
  50. package/dist/init-wizard.js +33 -0
  51. package/dist/monitor.d.ts +34 -3
  52. package/dist/monitor.js +174 -74
  53. package/dist/owner-channel/channel.d.ts +3 -0
  54. package/dist/owner-channel/channel.js +28 -6
  55. package/dist/owner-channel/ours-client.d.ts +12 -5
  56. package/dist/owner-channel/ours-client.js +48 -12
  57. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  58. package/dist/rooms-tasks/archived-absence.js +48 -0
  59. package/dist/rooms-tasks/cli.js +1 -1
  60. package/dist/rooms-tasks/close.d.ts +3 -0
  61. package/dist/rooms-tasks/close.js +95 -17
  62. package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
  63. package/dist/rooms-tasks/cowork-adapter.js +10 -2
  64. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  65. package/dist/rooms-tasks/cowork-http.js +70 -0
  66. package/dist/rooms-tasks/deletion.js +40 -3
  67. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  68. package/dist/rooms-tasks/member-startup.js +2 -0
  69. package/dist/rooms-tasks/provision.d.ts +11 -0
  70. package/dist/rooms-tasks/provision.js +138 -26
  71. package/dist/rooms-tasks/room-state.js +13 -0
  72. package/dist/rooms-tasks/task-state.d.ts +13 -3
  73. package/dist/rooms-tasks/task-state.js +42 -6
  74. package/dist/rooms-tasks/types.d.ts +12 -0
  75. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  76. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  77. package/dist/rooms-tasks/workspace.d.ts +26 -0
  78. package/dist/rooms-tasks/workspace.js +195 -0
  79. package/dist/runner.d.ts +14 -0
  80. package/dist/runner.js +540 -494
  81. package/dist/session/codex-app-server-transport.js +1 -1
  82. package/dist/session/codex-app-server.js +31 -5
  83. package/dist/spawn.js +19 -16
  84. package/dist/startup-readiness.d.ts +13 -0
  85. package/dist/startup-readiness.js +38 -0
  86. package/dist/supervisor/launchd.js +8 -1
  87. package/dist/supervisor/systemd.js +5 -0
  88. package/dist/temp-supervisor-recovery.d.ts +3 -0
  89. package/dist/temp-supervisor-recovery.js +46 -0
  90. package/dist/watchdog/briefing.js +3 -16
  91. package/dist/watchdog/run.js +19 -5
  92. package/dist/web/server.d.ts +2 -0
  93. package/dist/web/server.js +18 -1
  94. package/docs/task-workspaces.md +98 -0
  95. package/package.json +8 -4
  96. package/presets/fleet/roles/Coordinator.yaml +22 -0
@@ -419,7 +419,9 @@ export class OwnerChannel {
419
419
  ]), deadlineAt);
420
420
  if (superseded())
421
421
  throw new Error('owner recovery epoch superseded');
422
- await this.recoveryStage('close', this.client.close(), deadlineAt);
422
+ // A daemon reconnect is not terminal ownership evidence. Keep the same
423
+ // owner ID and its temporary/permanent bindings while replacing transport.
424
+ await this.recoveryStage('close', this.client.close({ releaseLease: false }), deadlineAt);
423
425
  if (superseded())
424
426
  throw new Error('owner recovery epoch superseded');
425
427
  try {
@@ -450,7 +452,7 @@ export class OwnerChannel {
450
452
  // its disposal; the recorded quiescence debt gates the next retry.
451
453
  if (!this.recoveryQuiescence) {
452
454
  try {
453
- await this.recoveryStage('close_after_failure', this.client.close(), deadlineAt);
455
+ await this.recoveryStage('close_after_failure', this.client.close({ releaseLease: false }), deadlineAt);
454
456
  }
455
457
  catch (closeError) {
456
458
  this.logError('recovery client close failed', closeError);
@@ -1121,6 +1123,13 @@ export class OwnerChannel {
1121
1123
  const retrieved = unread.length
1122
1124
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1123
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
+ }
1124
1133
  for (const file of historyRecovered) {
1125
1134
  if (!group.recovery)
1126
1135
  throw new Error('unexpected read attachment without recovery route');
@@ -1134,8 +1143,7 @@ export class OwnerChannel {
1134
1143
  await mkdir(outbox, { recursive: true, mode: 0o700 });
1135
1144
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1136
1145
  const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
1137
- interrupt: this.options.config.interrupt,
1138
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1146
+ ...this.ownerPromptPolicy(),
1139
1147
  origin: { kind: 'owner', requestId,
1140
1148
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
1141
1149
  });
@@ -1261,8 +1269,7 @@ export class OwnerChannel {
1261
1269
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1262
1270
  try {
1263
1271
  queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
1264
- interrupt: this.options.config.interrupt,
1265
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1272
+ ...this.ownerPromptPolicy(),
1266
1273
  origin: { kind: 'owner', requestId, displayText: text },
1267
1274
  });
1268
1275
  }
@@ -1307,6 +1314,14 @@ export class OwnerChannel {
1307
1314
  this.completionTasks.add(task);
1308
1315
  return true;
1309
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
+ }
1310
1325
  /**
1311
1326
  * Deterministic command path: the message never becomes an agent prompt.
1312
1327
  * Authorization already happened — the managed-agent relay branch and the
@@ -1760,6 +1775,13 @@ export class OwnerChannel {
1760
1775
  const retrieved = unread.length
1761
1776
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1762
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
+ }
1763
1785
  for (const file of historyRecovered) {
1764
1786
  if (!group.recovery)
1765
1787
  throw new Error('unexpected read attachment without recovery route');
@@ -92,8 +92,10 @@ export interface OursOps {
92
92
  filename: string;
93
93
  replyToWireId?: string;
94
94
  }): Promise<void>;
95
- /** Release the daemon lease and stop. Never throws. */
96
- close(): Promise<void>;
95
+ /** Dispose transport; terminal release is default and rejects if cleanup is incomplete. */
96
+ close(options?: {
97
+ releaseLease?: boolean;
98
+ }): Promise<void>;
97
99
  }
98
100
  /**
99
101
  * The typed error code of a daemon operation, or undefined when the failure was
@@ -120,14 +122,17 @@ export interface OursSdkClientDeps {
120
122
  * its own and hands it back in `close()`. That replaces the connector proxy's
121
123
  * shell-PID fence, which existed because a supervised attempt had to make its
122
124
  * lease reclaimable while the supervisor itself stayed alive: an explicit
123
- * release does that deterministically, and `clientPid` still covers the case
124
- * where the whole supervisor dies without unwinding.
125
+ * terminal release does that deterministically. Recovery disposes only the
126
+ * transport and retains this owner ID and its bindings. Explicit daemon identity selects external
127
+ * ownership in either layout; PID-based legacy attachment remains temporary.
128
+ * Failed external release preserves unknown ownership until exact terminal evidence.
125
129
  */
126
130
  export declare class OursSdkClient implements OursOps {
127
131
  private readonly env;
128
132
  private readonly log;
129
133
  private readonly deps;
130
134
  private client?;
135
+ private terminalReleasePending;
131
136
  private readonly leaseToken;
132
137
  constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
133
138
  start(): Promise<void>;
@@ -161,7 +166,9 @@ export declare class OursSdkClient implements OursOps {
161
166
  filename: string;
162
167
  replyToWireId?: string;
163
168
  }): Promise<void>;
164
- close(): Promise<void>;
169
+ close(options?: {
170
+ releaseLease?: boolean;
171
+ }): Promise<void>;
165
172
  private ops;
166
173
  }
167
174
  export {};
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { extname } from 'node:path';
3
+ import { extname, join } from 'node:path';
4
+ import { readDaemonConfig, resolveEndpoint } from '../monitor.js';
5
+ import { readClientProfile } from '../client-profile.js';
4
6
  import { OursError, attachOursClient, } from '@ours.network/sdk/client';
5
7
  /** Any failure of a daemon operation. Never carries a message body or a token. */
6
8
  export class OursDaemonError extends Error {
@@ -106,14 +108,18 @@ export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
106
108
  * its own and hands it back in `close()`. That replaces the connector proxy's
107
109
  * shell-PID fence, which existed because a supervised attempt had to make its
108
110
  * lease reclaimable while the supervisor itself stayed alive: an explicit
109
- * release does that deterministically, and `clientPid` still covers the case
110
- * where the whole supervisor dies without unwinding.
111
+ * terminal release does that deterministically. Recovery disposes only the
112
+ * transport and retains this owner ID and its bindings. Explicit daemon identity selects external
113
+ * ownership in either layout; PID-based legacy attachment remains temporary.
114
+ * Failed external release preserves unknown ownership until exact terminal evidence.
111
115
  */
112
116
  export class OursSdkClient {
113
117
  env;
114
118
  log;
115
119
  deps;
116
120
  client;
121
+ // Disposing a transport does not discharge this owner's terminal cleanup.
122
+ terminalReleasePending = false;
117
123
  leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
118
124
  constructor(env = {}, log = () => undefined, deps = {}) {
119
125
  this.env = env;
@@ -124,16 +130,28 @@ export class OursSdkClient {
124
130
  if (this.client)
125
131
  return;
126
132
  const environment = { ...process.env, ...this.env };
127
- // SDK 2's supported application path resolves endpoint, state root, and
128
- // token as one coherent selection, proves the daemon's state root before
129
- // sending credentials, and only then constructs the client.
130
- const options = {
133
+ const profile = readClientProfile(environment);
134
+ // Reuse existing connection configuration. Identity selection checks the
135
+ // daemon capability before reading the client's protected token delivery file.
136
+ const endpoint = !profile && environment.OURS_DAEMON_ID
137
+ ? resolveEndpoint(environment, false) : undefined;
138
+ const token = endpoint ? environment.OURS_API_TOKEN?.trim() || readDaemonConfig(environment).apiToken : undefined;
139
+ const options = profile ? {
140
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
141
+ credentialPath: profile.credentialPath,
142
+ sessionMode: 'external', leaseToken: this.leaseToken, env: {},
143
+ } : endpoint ? {
144
+ endpoint: endpoint.origin, expectedInstanceId: environment.OURS_DAEMON_ID,
145
+ sessionMode: 'external', leaseToken: this.leaseToken,
146
+ ...(token ? { token } : { credentialPath: join(endpoint.stateDir, 'daemon-token') }),
147
+ } : {
131
148
  env: environment,
132
149
  leaseToken: this.leaseToken,
133
150
  clientPid: process.pid,
134
151
  fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
135
152
  };
136
153
  this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
154
+ this.terminalReleasePending = true;
137
155
  }
138
156
  async bindIdentity(name) {
139
157
  // force is pinned off: the owner channel never evicts another live session
@@ -162,7 +180,10 @@ export class OursSdkClient {
162
180
  return this.ops().getHistoryItem({ wire_id: wireId });
163
181
  }
164
182
  watchNotifications(identity, options) {
165
- return this.ops().watchNotifications(identity, options);
183
+ return this.ops().watchNotifications(identity, {
184
+ ...options,
185
+ requestTimeoutMs: this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS,
186
+ });
166
187
  }
167
188
  async listIncomingFiles() {
168
189
  return this.ops().listIncomingFiles();
@@ -211,19 +232,34 @@ export class OursSdkClient {
211
232
  throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
212
233
  + 'session must be re-established after an upgrade; files are not queued');
213
234
  }
214
- async close() {
235
+ async close(options = {}) {
236
+ const terminal = options.releaseLease !== false;
237
+ // A failed recovery may have disposed its client before normal shutdown.
238
+ // Reattach the same owner through ordinary verified selection; no rebind is
239
+ // needed to release it, and a failed attach must not be reported as closed.
240
+ if (terminal && !this.client && this.terminalReleasePending)
241
+ await this.start();
215
242
  const client = this.client;
216
243
  this.client = undefined;
217
244
  if (!client)
218
245
  return;
219
246
  // Handing the lease back is what lets a successor bind this identity without
220
- // waiting for the supervisor to exit. A failure here is not fatal — the
221
- // daemon still reclaims the lease when this process dies.
247
+ // waiting for the supervisor to exit. External failure remains unknown;
248
+ // it is not permission to infer owner death or fall back to daemon PID checks.
222
249
  try {
223
- await client.releaseLease();
250
+ if (terminal) {
251
+ const result = await client.releaseLease();
252
+ if (result.failed > 0)
253
+ throw new OursDaemonError('daemon lease release incomplete');
254
+ this.terminalReleasePending = false;
255
+ }
224
256
  }
225
257
  catch (error) {
226
258
  this.log(`lease release failed: ${error?.message ?? String(error)}`);
259
+ throw error;
260
+ }
261
+ finally {
262
+ await client.close();
227
263
  }
228
264
  }
229
265
  ops() {
@@ -0,0 +1,8 @@
1
+ import type { ArchivedMemberAbsence, RoomMemberSeat } from './types.js';
2
+ /** Revalidate live facts; a persisted timestamp is never cleanup authority. */
3
+ export declare function verifyArchivedAbsence(proof: ArchivedMemberAbsence): Promise<void>;
4
+ /** After the verified archive has been consumed by explicit workspace deletion,
5
+ * still fence replacement live state and a recreated daemon identity on every retry.
6
+ */
7
+ export declare function verifyArchivedMemberStillAbsent(proof: ArchivedMemberAbsence): Promise<void>;
8
+ export declare function proveArchivedAbsence(seat: RoomMemberSeat): Promise<ArchivedMemberAbsence>;
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { agentDir } from '../paths.js';
4
+ import { tempArchiveForLaunch, tempArchiveForCreationAction, tempSupervisorLiveness } from '../temp-lifecycle.js';
5
+ import { assertMemberIdentityAbsent } from './close.js';
6
+ /** Revalidate live facts; a persisted timestamp is never cleanup authority. */
7
+ export async function verifyArchivedAbsence(proof) {
8
+ const absent = () => {
9
+ if (existsSync(agentDir(proof.name, true)))
10
+ throw new Error('Archived member has replacement live state');
11
+ };
12
+ absent();
13
+ const archive = tempArchiveForLaunch(proof.name, proof.launch_id);
14
+ const action = tempArchiveForCreationAction(proof.name, proof.action_id);
15
+ if (!archive || archive !== proof.archive_path || action?.path !== archive
16
+ || action.launchId !== proof.launch_id
17
+ || readFileSync(join(archive, '.identity'), 'utf8').trim() !== proof.name)
18
+ throw new Error('Archived member absence ownership proof mismatch');
19
+ if (await tempSupervisorLiveness(archive) !== 'stopped')
20
+ throw new Error('Archived member supervisor absence is not proven');
21
+ await verifyArchivedMemberStillAbsent(proof);
22
+ }
23
+ /** After the verified archive has been consumed by explicit workspace deletion,
24
+ * still fence replacement live state and a recreated daemon identity on every retry.
25
+ */
26
+ export async function verifyArchivedMemberStillAbsent(proof) {
27
+ const absent = () => {
28
+ if (existsSync(agentDir(proof.name, true)))
29
+ throw new Error('Archived member has replacement live state');
30
+ };
31
+ absent();
32
+ await assertMemberIdentityAbsent({
33
+ role_name: proof.name, slot: 'archived', cowork_role: 'archived', seat_state: 'removed',
34
+ });
35
+ absent();
36
+ }
37
+ export async function proveArchivedAbsence(seat) {
38
+ if (seat.identity_cid || seat.retirement?.phase !== 'identity_absent'
39
+ || !seat.launch?.launch_id || !seat.launch.action_id
40
+ || seat.launch.launch_id !== seat.retirement.launch_id || !seat.retirement.archive_path)
41
+ throw new Error('Missing exact archived member absence evidence');
42
+ const proof = {
43
+ name: seat.role_name, launch_id: seat.launch.launch_id, action_id: seat.launch.action_id,
44
+ archive_path: seat.retirement.archive_path, checked_at: new Date().toISOString(),
45
+ };
46
+ await verifyArchivedAbsence(proof);
47
+ return proof;
48
+ }
@@ -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,8 @@ 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 const CLOSE_LOCK_STALE_MS: number;
6
+ export declare function roomCloseLockPath(roomId: string): string;
5
7
  export interface RoomCloseDeps {
6
8
  inspectMember?(seat: RoomMemberSeat): Promise<{
7
9
  launchId: string;
@@ -19,6 +21,7 @@ export declare function waitForLivenessAbsent(role: string, launchId: string, li
19
21
  /** Report whether any daemon identity — under any name — carries this exact CID. */
20
22
  export declare function identityCidPresent(cid: string): Promise<boolean>;
21
23
  export declare function removeExactMemberIdentity(seat: RoomMemberSeat): Promise<void>;
24
+ export declare function assertMemberIdentityAbsent(seat: RoomMemberSeat): Promise<void>;
22
25
  /** One forward-only room close saga shared by every Fleet entry point. */
23
26
  export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrchestrationRecord>;
24
27
  export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
@@ -1,16 +1,19 @@
1
+ import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
2
+ import { collectWorkspaceArchives } from './workspace-artifacts.js';
1
3
  import { existsSync, readFileSync } from 'node:fs';
2
4
  import { join } from 'node:path';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { attachOursClient } from '@ours.network/sdk/client';
5
7
  import { withFileLock } from '../atomic-file.js';
6
8
  import { agentDir, stateRoot } from '../paths.js';
7
- import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
9
+ import { readClientProfile } from '../client-profile.js';
10
+ import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, tempArchiveForLaunch, tempArchiveForCreationAction, } from '../temp-lifecycle.js';
8
11
  import { CoworkProtocolError } from './cowork-adapter.js';
9
12
  import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
10
- const CLOSE_LOCK_STALE_MS = 5 * 60_000;
13
+ export const CLOSE_LOCK_STALE_MS = 5 * 60_000;
11
14
  const STOP_POLLS = 50;
12
15
  const STOP_POLL_MS = 100;
13
- function roomCloseLockPath(roomId) {
16
+ export function roomCloseLockPath(roomId) {
14
17
  return join(stateRoot(), 'locks', 'room-close', encodeURIComponent(roomId));
15
18
  }
16
19
  function errorText(error) {
@@ -54,16 +57,28 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
54
57
  throw new Error(`temporary role '${role}' did not reach proven stopped liveness`);
55
58
  }
56
59
  async function withIdentityClient(work) {
57
- const client = await attachOursClient({
60
+ const profile = readClientProfile(process.env);
61
+ const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
62
+ const client = await attachOursClient(profile ? {
63
+ endpoint: profile.endpoint,
64
+ expectedInstanceId: profile.expectedInstanceId,
65
+ credentialPath: profile.credentialPath,
66
+ sessionMode: 'external', env: {}, leaseToken,
67
+ } : {
58
68
  env: process.env,
59
- leaseToken: `ours-fleet-room-close-${process.pid}-${randomUUID()}`,
69
+ leaseToken,
60
70
  clientPid: process.pid,
61
71
  });
62
72
  try {
63
73
  return await work(client);
64
74
  }
65
75
  finally {
66
- await client.releaseLease().catch(() => { });
76
+ try {
77
+ await client.releaseLease().catch(() => { });
78
+ }
79
+ finally {
80
+ await client.close();
81
+ }
67
82
  }
68
83
  }
69
84
  function listedIdentity(rows, name) {
@@ -106,11 +121,57 @@ export async function removeExactMemberIdentity(seat) {
106
121
  }
107
122
  });
108
123
  }
124
+ export async function assertMemberIdentityAbsent(seat) {
125
+ await withIdentityClient(async (client) => {
126
+ const rows = await client.listIdentities();
127
+ if (rows.some(row => row.name === seat.role_name ||
128
+ (seat.identity_cid && 'cid' in row && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
129
+ throw new Error(`room member '${seat.role_name}' identity absence is not proven; refusing retirement`);
130
+ }
131
+ });
132
+ }
109
133
  async function retireMember(roomId, seat, deps) {
110
134
  let room = getRoomRecord(roomId);
111
135
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
112
136
  let retirement = current.retirement;
137
+ if (retirement?.phase === 'identity_absent') {
138
+ if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
139
+ if (existsSync(agentDir(current.role_name, true)))
140
+ throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
141
+ await assertMemberIdentityAbsent(current);
142
+ }
143
+ return;
144
+ }
113
145
  if (!retirement) {
146
+ if (!existsSync(agentDir(current.role_name, true)) && current.launch?.launch_id && current.launch.action_id) {
147
+ const archived = tempArchiveForLaunch(current.role_name, current.launch.launch_id);
148
+ const created = tempArchiveForCreationAction(current.role_name, current.launch.action_id);
149
+ if (archived && created?.path === archived && created.launchId === current.launch.launch_id) {
150
+ if (readFileSync(join(archived, '.identity'), 'utf8').trim() !== current.role_name)
151
+ throw new Error(`room member '${current.role_name}' archive identity mismatch`);
152
+ if (await tempSupervisorLiveness(archived) !== 'stopped')
153
+ throw new Error(`room member '${current.role_name}' archived supervisor is not proven stopped`);
154
+ // A terminated launch can archive itself before room retirement begins.
155
+ // Accept its exact durable provenance only when no identity needs removal.
156
+ await assertMemberIdentityAbsent(current);
157
+ const latest = getRoomRecord(roomId)?.member_seats.find(seat => seat.role_name === current.role_name);
158
+ if (existsSync(agentDir(current.role_name, true))
159
+ || latest?.launch?.launch_id !== current.launch.launch_id
160
+ || latest?.launch?.action_id !== current.launch.action_id
161
+ || latest?.identity_cid !== current.identity_cid)
162
+ throw new Error(`room member '${current.role_name}' changed during archived retirement proof`);
163
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', current.launch.launch_id, archived);
164
+ return;
165
+ }
166
+ }
167
+ if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
168
+ // A failure before applyRole (for example invite-secret validation) has
169
+ // no supervisor to stop or archive. Settle only proven absence; this
170
+ // path never removes an identity based on missing local evidence.
171
+ await assertMemberIdentityAbsent(current);
172
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
173
+ return;
174
+ }
114
175
  if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
115
176
  if (existsSync(agentDir(current.role_name, true))) {
116
177
  throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
@@ -159,8 +220,6 @@ export async function closeManagedRoom(input) {
159
220
  try {
160
221
  if (room.close?.phase === 'retire_members') {
161
222
  for (const seat of room.member_seats) {
162
- if (seat.retirement?.phase === 'identity_absent')
163
- continue;
164
223
  await retireMember(input.roomId, seat, deps);
165
224
  }
166
225
  room = advanceRoomClose(input.roomId, 'close_cowork');
@@ -180,6 +239,9 @@ export async function closeManagedRoom(input) {
180
239
  export async function deleteLegacyClosedRooms(input) {
181
240
  const deleted = [];
182
241
  for (const room of listRoomRecords({ state: 'closed' })) {
242
+ // This compatibility sweep is retirement, never explicit workspace deletion.
243
+ if (room.workspace)
244
+ continue;
183
245
  try {
184
246
  await input.cowork.deleteRoom(room.room_id);
185
247
  }
@@ -197,13 +259,29 @@ export async function deleteLegacyClosedRooms(input) {
197
259
  /** Retire live resources through the existing cursor, then delete retained state. */
198
260
  export async function deleteManagedRoom(input) {
199
261
  await closeManagedRoom(input);
200
- try {
201
- await input.cowork.deleteRoom(input.roomId);
202
- }
203
- catch (error) {
204
- setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'.`);
205
- throw error;
206
- }
207
- deleteRoomRecord(input.roomId);
208
- return { room_id: input.roomId, deleted: true };
262
+ return withFileLock(roomCloseLockPath(input.roomId), async () => {
263
+ try {
264
+ try {
265
+ await input.cowork.deleteRoom(input.roomId);
266
+ }
267
+ catch (error) {
268
+ // An earlier explicit delete may have removed the remote room before
269
+ // local filesystem cleanup failed. Never restore it or repeat creation.
270
+ if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
271
+ throw error;
272
+ }
273
+ const retained = getRoomRecord(input.roomId);
274
+ if (retained?.workspace && !retained.task_id) {
275
+ assertWorkspaceDeletable(retained.workspace, 'room', input.roomId);
276
+ collectWorkspaceArchives(retained.workspace);
277
+ deleteWorkspace(retained.workspace, 'room', input.roomId);
278
+ }
279
+ deleteRoomRecord(input.roomId);
280
+ return { room_id: input.roomId, deleted: true };
281
+ }
282
+ catch (error) {
283
+ setRoomCloseError(input.roomId, errorText(error), `Fix the cleanup error and explicitly retry room delete ${input.roomId} ${input.roomId}.`);
284
+ throw error;
285
+ }
286
+ }, {}, CLOSE_LOCK_STALE_MS);
209
287
  }
@@ -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
+ }