@ours.network/fleet 0.18.1 → 0.19.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 (90) hide show
  1. package/README.md +86 -90
  2. package/dist/application/fleet-query-service.js +0 -12
  3. package/dist/application/role-creation-service.js +10 -7
  4. package/dist/application/types.d.ts +0 -11
  5. package/dist/briefing.js +6 -15
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +12 -37
  8. package/dist/config.d.ts +14 -6
  9. package/dist/config.js +58 -31
  10. package/dist/creation.d.ts +38 -22
  11. package/dist/creation.js +111 -24
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +71 -94
  14. package/dist/doctor.d.ts +5 -1
  15. package/dist/doctor.js +143 -12
  16. package/dist/fleet-proxy.d.ts +0 -5
  17. package/dist/harness/acp-agent.js +6 -11
  18. package/dist/harness/claude-code.js +11 -200
  19. package/dist/harness/codex.d.ts +1 -4
  20. package/dist/harness/codex.js +12 -70
  21. package/dist/harness/types.d.ts +4 -54
  22. package/dist/loops/manager.d.ts +1 -30
  23. package/dist/loops/manager.js +6 -69
  24. package/dist/loops/state.d.ts +0 -18
  25. package/dist/loops/state.js +0 -4
  26. package/dist/monitor.js +1 -1
  27. package/dist/ops.d.ts +3 -0
  28. package/dist/ops.js +8 -3
  29. package/dist/owner-channel/attachments.d.ts +25 -2
  30. package/dist/owner-channel/attachments.js +61 -5
  31. package/dist/owner-channel/channel.d.ts +36 -30
  32. package/dist/owner-channel/channel.js +338 -291
  33. package/dist/owner-channel/commands.d.ts +9 -0
  34. package/dist/owner-channel/commands.js +393 -0
  35. package/dist/owner-channel/message-recovery.d.ts +25 -0
  36. package/dist/owner-channel/message-recovery.js +114 -0
  37. package/dist/owner-channel/notices.d.ts +0 -7
  38. package/dist/owner-channel/notices.js +0 -9
  39. package/dist/owner-channel/ours-client.d.ts +148 -0
  40. package/dist/owner-channel/ours-client.js +231 -0
  41. package/dist/rooms-tasks/cli.d.ts +4 -0
  42. package/dist/rooms-tasks/cli.js +1102 -0
  43. package/dist/rooms-tasks/close.d.ts +21 -0
  44. package/dist/rooms-tasks/close.js +159 -0
  45. package/dist/rooms-tasks/config.d.ts +15 -0
  46. package/dist/rooms-tasks/config.js +174 -0
  47. package/dist/rooms-tasks/cowork-adapter.d.ts +77 -0
  48. package/dist/rooms-tasks/cowork-adapter.js +272 -0
  49. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  50. package/dist/rooms-tasks/external-worker.js +40 -0
  51. package/dist/rooms-tasks/index.d.ts +10 -0
  52. package/dist/rooms-tasks/index.js +10 -0
  53. package/dist/rooms-tasks/provision.d.ts +21 -0
  54. package/dist/rooms-tasks/provision.js +243 -0
  55. package/dist/rooms-tasks/room-state.d.ts +28 -0
  56. package/dist/rooms-tasks/room-state.js +221 -0
  57. package/dist/rooms-tasks/task-state.d.ts +43 -0
  58. package/dist/rooms-tasks/task-state.js +277 -0
  59. package/dist/rooms-tasks/templates.d.ts +6 -0
  60. package/dist/rooms-tasks/templates.js +94 -0
  61. package/dist/rooms-tasks/terminal.d.ts +23 -0
  62. package/dist/rooms-tasks/terminal.js +53 -0
  63. package/dist/rooms-tasks/types.d.ts +187 -0
  64. package/dist/rooms-tasks/types.js +20 -0
  65. package/dist/runner.d.ts +0 -48
  66. package/dist/runner.js +94 -236
  67. package/dist/session/acp.d.ts +0 -104
  68. package/dist/session/acp.js +10 -213
  69. package/dist/session/conversation-normalizer.d.ts +0 -6
  70. package/dist/session/conversation-normalizer.js +10 -153
  71. package/dist/session/conversation-types.d.ts +4 -23
  72. package/dist/session/types.d.ts +0 -35
  73. package/dist/spawn.js +26 -33
  74. package/dist/supervisor/systemd.js +29 -2
  75. package/dist/temp-lifecycle.d.ts +9 -2
  76. package/dist/temp-lifecycle.js +61 -0
  77. package/dist/watchdog/briefing.js +0 -7
  78. package/dist/watchdog/run.js +3 -3
  79. package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
  80. package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
  81. package/dist/web-app/index.html +1 -1
  82. package/dist/worklog.d.ts +1 -7
  83. package/dist/worklog.js +39 -191
  84. package/package.json +3 -1
  85. package/dist/model-env.d.ts +0 -71
  86. package/dist/model-env.js +0 -106
  87. package/dist/owner-channel/mcp.d.ts +0 -24
  88. package/dist/owner-channel/mcp.js +0 -145
  89. package/dist/session/activity.d.ts +0 -31
  90. package/dist/session/activity.js +0 -48
package/dist/config.d.ts CHANGED
@@ -11,9 +11,6 @@ export interface WorklogPolicy {
11
11
  keep_tail_kb: number;
12
12
  max_archives: number;
13
13
  }
14
- export type WorklogPolicyInput = Partial<WorklogPolicy> | false;
15
- /** Conservative built-in policy; `worklog: false` is the explicit opt-out. */
16
- export declare const DEFAULT_WORKLOG_POLICY: Readonly<WorklogPolicy>;
17
14
  export interface AuthProxyConfig {
18
15
  kind: 'anthropic';
19
16
  base_url: string;
@@ -133,10 +130,10 @@ export interface RoleConfig {
133
130
  isolation?: IsolationConfig;
134
131
  monitor?: Partial<MonitorConfig>;
135
132
  owner_channel?: OwnerChannelConfigInput;
136
- worklog?: WorklogPolicyInput;
133
+ worklog?: WorklogPolicy;
137
134
  auth_proxy?: Partial<AuthProxyConfig>;
138
135
  }
139
- export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel' | 'worklog'> {
136
+ export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel'> {
140
137
  name: string;
141
138
  harness: string;
142
139
  session: SessionBackendId;
@@ -168,6 +165,17 @@ export interface FleetConfig {
168
165
  diagnostics: ConfigDiagnostic[];
169
166
  watchdogs: ResolvedWatchdog[];
170
167
  loops: ResolvedLoop[];
168
+ rooms?: import('./rooms-tasks/types.js').RoomsConfig;
169
+ roomTemplates?: import('./rooms-tasks/types.js').RoomTemplatesConfig;
170
+ tasks?: import('./rooms-tasks/types.js').TasksConfig;
171
+ /** SHA-256 fingerprint of the resolved owner invite (never the invite itself). */
172
+ ownerInviteFingerprint?: string;
173
+ /**
174
+ * Resolved owner invite for immediate private-IPC use only. This property is
175
+ * deliberately non-enumerable so config dumps and JSON output cannot expose
176
+ * the bearer credential.
177
+ */
178
+ ownerInvite?: string;
171
179
  }
172
180
  export declare class ConfigError extends Error {
173
181
  }
@@ -196,7 +204,7 @@ export declare function canonicalCid(value: string): string;
196
204
  export declare function resolveOwnerChannelConfig(defaults: unknown, role: OwnerChannelConfigInput | undefined, session: SessionBackendId, file?: string, name?: string): OwnerChannelConfig | undefined;
197
205
  export declare function resolveModelChain(model: string | undefined, chain: string[] | undefined, file?: string, name?: string): string[] | undefined;
198
206
  export declare function resolveAuthProxy(defaults: unknown, role: Partial<AuthProxyConfig> | undefined, file?: string, name?: string): AuthProxyConfig | undefined;
199
- export declare function resolveWorklogPolicy(defaults: unknown, role: WorklogPolicyInput | undefined, file?: string, name?: string): WorklogPolicy | undefined;
207
+ export declare function resolveWorklogPolicy(defaults: unknown, role: WorklogPolicy | undefined, file?: string, name?: string): WorklogPolicy | undefined;
200
208
  export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
201
209
  /**
202
210
  * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
package/dist/config.js CHANGED
@@ -4,17 +4,11 @@ import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
4
4
  import { parseFleetDocument, } from './config-yaml.js';
5
5
  import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
- import { resolveRoleModelEnv } from './model-env.js';
8
7
  import { resolveWatchdogs } from './watchdog/config.js';
9
8
  import { resolveLoops } from './loops/config.js';
9
+ import { validateRoomsConfig, validateTasksConfig, validateRoomTemplatesConfig, } from './rooms-tasks/config.js';
10
10
  import { CAPABILITIES, CAP_MONITOR_INTERRUPT_AFTER_TOOL } from './capabilities.js';
11
11
  import { runningLabel } from './provenance.js';
12
- /** Conservative built-in policy; `worklog: false` is the explicit opt-out. */
13
- export const DEFAULT_WORKLOG_POLICY = Object.freeze({
14
- max_kb: 1024,
15
- keep_tail_kb: 256,
16
- max_archives: 12,
17
- });
18
12
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
19
13
  export const NOTIFY_EVENT_TYPES = [
20
14
  'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
@@ -171,9 +165,10 @@ export function loadConfig(configPath, options = {}) {
171
165
  const parsed = parseFleetDocument(p, readFileSync(p, 'utf8'), options.yamlMode);
172
166
  const doc = parsed.value;
173
167
  diagnostics.push(...parsed.diagnostics);
174
- const extra = Object.keys(doc).filter(k => k !== 'roles');
168
+ const FLEET_D_ALLOWED = ['roles', 'rooms', 'room_templates', 'tasks'];
169
+ const extra = Object.keys(doc).filter(k => !FLEET_D_ALLOWED.includes(k));
175
170
  if (extra.length)
176
- throw new ConfigError(`${p}: fleet.d files may only define roles: (found: ${extra.join(', ')})`);
171
+ throw new ConfigError(`${p}: fleet.d files may only define ${FLEET_D_ALLOWED.join(', ')}: (found: ${extra.join(', ')})`);
177
172
  docs.push({ file: p, doc });
178
173
  files.push(p);
179
174
  }
@@ -224,23 +219,17 @@ export function loadConfig(configPath, options = {}) {
224
219
  const harness = r.harness ?? defaults.harness ?? 'claude-code';
225
220
  const defaultHarness = defaults.harness ?? 'claude-code';
226
221
  const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
227
- if (authProxy && harness !== 'claude-code')
228
- throw new ConfigError(`${file}: role '${name}' auth_proxy is supported only by claude-code`);
229
- // Environment and runtime model are resolved together so `model:` and the
230
- // harness's model pin can never disagree (see src/model-env.ts).
231
- const modelEnv = resolveRoleModelEnv({
232
- harness,
233
- model: resolveRoleModel(r.model, r.harness, defaults),
234
- modelWasExplicit: r.model !== undefined,
235
- defaultsEnv: (defaults.env ?? {}),
236
- roleEnv: r.env,
237
- ...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
238
- }, message => new ConfigError(`${file}: role '${name}' ${message}`));
239
- const env = modelEnv.env;
240
- const model = modelEnv.model;
222
+ const model = resolveRoleModel(r.model, r.harness, defaults);
241
223
  const modelChain = resolveModelChain(model, r.model_chain ?? (inheritsModelDefaults
242
224
  ? defaults.model_chain
243
225
  : undefined), file, name);
226
+ if (authProxy && harness !== 'claude-code')
227
+ throw new ConfigError(`${file}: role '${name}' auth_proxy is supported only by claude-code`);
228
+ const env = {
229
+ ...(defaults.env ?? {}),
230
+ ...(r.env ?? {}),
231
+ ...(authProxy ? { ANTHROPIC_BASE_URL: authProxy.base_url } : {}),
232
+ };
244
233
  roles.push({
245
234
  ...r,
246
235
  name,
@@ -281,10 +270,49 @@ export function loadConfig(configPath, options = {}) {
281
270
  const resolvedLoops = resolveLoops(baseDoc.loops, base, roles, vars);
282
271
  for (const role of roles)
283
272
  role.loops = resolvedLoops.byRole.get(role.name) ?? [];
284
- return {
273
+ // ── Rooms, room_templates, tasks (split-config merge) ──────────────
274
+ // These sections may appear in the base fleet.yaml and/or in fleet.d files.
275
+ // Base provides defaults; fleet.d extends. Only one source may define rooms/tasks
276
+ // top-level (room_templates merge by name, last writer wins).
277
+ let rooms;
278
+ let ownerInviteFingerprint;
279
+ let ownerInvite;
280
+ let roomTemplates;
281
+ let tasks;
282
+ for (const { file, doc } of docs) {
283
+ if (doc.rooms !== undefined) {
284
+ if (rooms)
285
+ throw new ConfigError(`rooms: defined in multiple files; last: ${file}`);
286
+ const validated = validateRoomsConfig(deepSub(doc.rooms, vars), vars, file);
287
+ ownerInviteFingerprint = validated._invite?.fingerprint;
288
+ ownerInvite = validated._invite?.value;
289
+ const { _invite: _, ...clean } = validated;
290
+ rooms = clean;
291
+ }
292
+ if (doc.room_templates !== undefined) {
293
+ const validated = validateRoomTemplatesConfig(deepSub(doc.room_templates, vars), file);
294
+ roomTemplates = { ...(roomTemplates ?? {}), ...validated };
295
+ }
296
+ if (doc.tasks !== undefined) {
297
+ if (tasks)
298
+ throw new ConfigError(`tasks: defined in multiple files; last: ${file}`);
299
+ tasks = validateTasksConfig(deepSub(doc.tasks, vars), file);
300
+ }
301
+ }
302
+ const result = {
285
303
  roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
286
304
  loops: resolvedLoops.loops,
305
+ rooms, roomTemplates, tasks, ownerInviteFingerprint,
287
306
  };
307
+ if (ownerInvite !== undefined) {
308
+ Object.defineProperty(result, 'ownerInvite', {
309
+ value: ownerInvite,
310
+ enumerable: false,
311
+ writable: false,
312
+ configurable: false,
313
+ });
314
+ }
315
+ return result;
288
316
  }
289
317
  /**
290
318
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
@@ -474,16 +502,15 @@ export function resolveAuthProxy(defaults, role, file = 'config', name = 'role')
474
502
  };
475
503
  }
476
504
  export function resolveWorklogPolicy(defaults, role, file = 'config', name = 'role') {
477
- if (role === false || (role === undefined && defaults === false))
505
+ if (defaults === undefined && role === undefined)
478
506
  return undefined;
479
- if (defaults !== undefined && defaults !== false && !isPlainObject(defaults))
480
- throw new ConfigError(`${file}: defaults.worklog must be a map or false`);
507
+ if (defaults !== undefined && !isPlainObject(defaults))
508
+ throw new ConfigError(`${file}: defaults.worklog must be a map`);
481
509
  if (role !== undefined && !isPlainObject(role))
482
- throw new ConfigError(`${file}: role '${name}' worklog must be a map or false`);
510
+ throw new ConfigError(`${file}: role '${name}' worklog must be a map`);
483
511
  const merged = {
484
- ...DEFAULT_WORKLOG_POLICY,
485
- ...(defaults === false || defaults === undefined ? {} : defaults),
486
- ...(role === undefined ? {} : role),
512
+ ...(defaults ?? {}),
513
+ ...(role ?? {}),
487
514
  };
488
515
  const bad = Object.keys(merged).filter(key => !['max_kb', 'keep_tail_kb', 'max_archives'].includes(key));
489
516
  if (bad.length)
@@ -1,5 +1,6 @@
1
+ import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
1
2
  import { type LockDeps } from './atomic-file.js';
2
- import { type FetchLike } from './monitor.js';
3
+ import type { ResolvedRole } from './config.js';
3
4
  export type ReservationKind = 'role' | 'identity';
4
5
  export interface Reservation {
5
6
  kind: ReservationKind;
@@ -82,26 +83,30 @@ export declare function clearStaleReservations(olderThanMs?: number, now?: numbe
82
83
  * Identity provisioning (7.3). The fleet must know — before the harness starts
83
84
  * — whether the role's identity exists, and create it when it does not.
84
85
  *
85
- * `exists()` is answerable today: the daemon's authenticated `/identities`
86
- * endpoint is already used by doctor. `create()` is NOT: `ours-mcp` exposes only
87
- * `create-root`, and role identities are minted through the MCP `create_identity`
88
- * tool inside an agent session. So creation is a seam, injected by whoever can
89
- * satisfy it, and its absence is reported rather than papered over.
86
+ * `exists()` is answered through the typed SDK daemon inventory. `create()` is
87
+ * intentionally still a seam: fleet preserves application-owned identity
88
+ * creation/binding, especially the session-owned lifetime of temporary roles.
89
+ * Its absence is reported rather than papered over.
90
90
  */
91
91
  export interface IdentityProvisioner {
92
92
  /** Does this identity exist? `unknown` when the daemon could not be asked. */
93
93
  exists(name: string): Promise<boolean | 'unknown'>;
94
94
  /** Create it, publishing bio/persona through the same path. Absent = cannot. */
95
- create?(name: string, profile: {
96
- bio?: string;
97
- persona?: string;
98
- }): Promise<void>;
95
+ create?(name: string, profile: IdentityProvisionProfile): Promise<void>;
99
96
  /**
100
97
  * Undo a `create` during rollback. Only ever called for an identity THIS
101
98
  * transaction created; absent means "cannot", and the orphan is reported.
102
99
  */
103
100
  remove?(name: string): Promise<void>;
104
101
  }
102
+ export interface IdentityProvisionProfile {
103
+ bio?: string;
104
+ persona?: string;
105
+ /** Permanent role identities are locally discoverable; control identities opt out. */
106
+ exposeLocal?: boolean;
107
+ /** Permanent role identities accept sibling introductions; control identities opt out. */
108
+ localAutoAccept?: boolean;
109
+ }
105
110
  export type IdentityGuarantee = {
106
111
  state: 'verified';
107
112
  evidence: 'verified';
@@ -115,6 +120,8 @@ export type IdentityGuarantee = {
115
120
  evidence: 'missing' | 'unknown';
116
121
  detail: string;
117
122
  };
123
+ /** Reconcile every permanent identity a role's supervisor owns before launch. */
124
+ export declare function reconcilePermanentRoleIdentities(role: ResolvedRole, provisioner?: IdentityProvisioner, log?: (line: string) => void, knownRoleGuarantee?: IdentityGuarantee['state']): Promise<'verified' | 'created'>;
118
125
  /**
119
126
  * Establish the identity before the role's service is enabled.
120
127
  *
@@ -123,21 +130,29 @@ export type IdentityGuarantee = {
123
130
  * the failure a real agent hit on its first boot, having been told to bind an
124
131
  * identity that did not exist.
125
132
  */
126
- export declare function ensureIdentity(name: string, profile: {
127
- bio?: string;
128
- persona?: string;
129
- }, provisioner: IdentityProvisioner | undefined, log?: (line: string) => void): Promise<IdentityGuarantee>;
133
+ export declare function ensureIdentity(name: string, profile: IdentityProvisionProfile, provisioner: IdentityProvisioner | undefined, log?: (line: string) => void): Promise<IdentityGuarantee>;
130
134
  /**
131
- * Ask the running ours daemon whether an identity exists, over the same
132
- * authenticated endpoint doctor already probes. Answers `unknown` rather than
133
- * guessing when the daemon cannot be reached — an unreachable daemon is not
134
- * evidence that the identity is missing.
135
+ * Ask the running ours daemon whether an identity exists through SDK 2's
136
+ * coherence-checking attach path. Answers `unknown` rather than guessing when
137
+ * the daemon cannot be reached — an unreachable daemon is not evidence that
138
+ * the identity is missing.
135
139
  *
136
- * It deliberately has no `create()`: role identities are minted through the MCP
137
- * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
138
- * failure mode this release exists to stop.
140
+ * Permanent identities can be created before a harness starts. Creation binds
141
+ * the provisioning lease, so it is always released before the role or channel
142
+ * takes ownership. Temporary callers disable creation: a temporary identity is
143
+ * owned and deleted by the exact connector lease that created it, and there is
144
+ * no safe lease-transfer operation in the current SDK.
139
145
  */
140
- export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, fetchImpl?: FetchLike): IdentityProvisioner;
146
+ type IdentityInventoryClient = Pick<OursClient, 'identities'> & Partial<Pick<OursClient, 'listIdentities' | 'createIdentity' | 'setPersona' | 'releaseLease'>>;
147
+ export interface DaemonIdentityProvisionerOptions {
148
+ /** False for temporary roles/watchdogs whose connector must own creation. */
149
+ createPermanent?: boolean;
150
+ /** True only for inventory checks performed by a temporary lifecycle. */
151
+ acceptTemporaryExisting?: boolean;
152
+ }
153
+ export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>, options?: DaemonIdentityProvisionerOptions): IdentityProvisioner;
154
+ /** Inventory-only variant for session-owned temporary identity lifecycles. */
155
+ export declare function daemonIdentityInventoryProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>): IdentityProvisioner;
141
156
  /** Atomically write a role's fleet.d file, journalling it for rollback. */
142
157
  export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
143
158
  /** Where a setting's effective value came from. */
@@ -207,3 +222,4 @@ export declare function creationBuildNote(p: CreationProvenance): string | undef
207
222
  export declare function formatProvenance(p: CreationProvenance): string[];
208
223
  /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
209
224
  export declare function provenanceOf(cliValue: unknown, fleetDefault: unknown, builtIn?: unknown): ProvenanceEntry;
225
+ export {};
package/dist/creation.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { attachOursClient, } from '@ours.network/sdk/client';
3
5
  import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
6
  import { stateRoot } from './paths.js';
5
- import { resolveEndpoint } from './monitor.js';
6
7
  import { buildInfo, UNKNOWN_BUILD } from './provenance.js';
7
8
  /**
8
9
  * One creation transaction: role name and ours identity reserved together,
@@ -141,6 +142,37 @@ function readdirSyncSafe(dir) {
141
142
  return [];
142
143
  }
143
144
  }
145
+ const requireGuaranteedIdentity = (role, identity, result) => {
146
+ if (result.state !== 'unverified')
147
+ return result;
148
+ throw new Error(`role '${role.name}': could not establish permanent ours identity '${identity}' before launch: ${result.detail}`);
149
+ };
150
+ /** Reconcile every permanent identity a role's supervisor owns before launch. */
151
+ export async function reconcilePermanentRoleIdentities(role, provisioner = daemonIdentityProvisioner(), log = () => { }, knownRoleGuarantee) {
152
+ const roleResult = knownRoleGuarantee
153
+ ? {
154
+ state: knownRoleGuarantee,
155
+ evidence: knownRoleGuarantee === 'verified' ? 'verified' : 'missing',
156
+ detail: `identity was ${knownRoleGuarantee} by the creation transaction`,
157
+ }
158
+ : await ensureIdentity(role.identity, {
159
+ bio: role.bio,
160
+ persona: role.persona,
161
+ exposeLocal: true,
162
+ localAutoAccept: true,
163
+ }, provisioner, log);
164
+ const roleGuarantee = requireGuaranteedIdentity(role, role.identity, roleResult);
165
+ if (role.owner_channel) {
166
+ const channelIdentity = role.owner_channel.identity;
167
+ const channel = await ensureIdentity(channelIdentity, {
168
+ bio: `Authenticated owner channel for the ours-fleet ${role.name} role.`,
169
+ exposeLocal: false,
170
+ localAutoAccept: false,
171
+ }, provisioner, log);
172
+ requireGuaranteedIdentity(role, channelIdentity, channel);
173
+ }
174
+ return roleGuarantee.state;
175
+ }
144
176
  /**
145
177
  * Establish the identity before the role's service is enabled.
146
178
  *
@@ -174,10 +206,9 @@ export async function ensureIdentity(name, profile, provisioner, log = () => { }
174
206
  detail: 'the ours daemon could not be asked',
175
207
  };
176
208
  if (!provisioner.create) {
177
- // Loud, and named. The briefing will tell the agent to mint it — which is
178
- // what actually happens today but nobody is told it was "predefined".
179
- log(`identity '${name}' does not exist and this host cannot create one automatically`
180
- + `the role will be told to mint it on first boot. Create it in advance to avoid that.`);
209
+ // Loud, and named. Permanent lifecycle callers fail closed here; temporary
210
+ // callers preserve connector-owned first-boot creation semantics.
211
+ log(`identity '${name}' does not exist and this provisioner cannot create one automatically`);
181
212
  return {
182
213
  state: 'unverified', evidence: 'missing',
183
214
  detail: 'it does not exist and cannot be created here',
@@ -189,29 +220,85 @@ export async function ensureIdentity(name, profile, provisioner, log = () => { }
189
220
  detail: 'created during spawn, with its bio and persona published',
190
221
  };
191
222
  }
192
- /**
193
- * Ask the running ours daemon whether an identity exists, over the same
194
- * authenticated endpoint doctor already probes. Answers `unknown` rather than
195
- * guessing when the daemon cannot be reached — an unreachable daemon is not
196
- * evidence that the identity is missing.
197
- *
198
- * It deliberately has no `create()`: role identities are minted through the MCP
199
- * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
200
- * failure mode this release exists to stop.
201
- */
202
- export function daemonIdentityProvisioner(env = process.env, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
203
- return {
223
+ export function daemonIdentityProvisioner(env = process.env, attachClient = attachOursClient, options = {}) {
224
+ const provisioner = {
204
225
  async exists(name) {
205
- const ep = resolveEndpoint(env);
206
- const resp = await fetchImpl(`${ep.origin}/identities`, { headers: ep.headers });
207
- if (!resp.ok)
208
- return 'unknown';
209
- const body = await resp.json();
210
- if (!Array.isArray(body.identities))
226
+ try {
227
+ const client = await attachClient({
228
+ env, leaseToken: `ours-fleet-identity-preflight-${process.pid}`, clientPid: process.pid,
229
+ });
230
+ const identities = await client.identities();
231
+ if (!Array.isArray(identities))
232
+ return 'unknown';
233
+ const existing = identities.find(identity => identity.name === name);
234
+ if (!existing)
235
+ return false;
236
+ // A session-owned temporary identity cannot satisfy a permanent role
237
+ // invariant. Report it as absent so create() can reject the collision
238
+ // explicitly after inspecting the authoritative identity tree.
239
+ if (existing.temporary && options.acceptTemporaryExisting !== true)
240
+ return false;
241
+ return true;
242
+ }
243
+ catch {
211
244
  return 'unknown';
212
- return body.identities.some(i => (typeof i === 'string' ? i : i?.name) === name);
245
+ }
213
246
  },
214
247
  };
248
+ if (options.createPermanent === false)
249
+ return provisioner;
250
+ provisioner.create = async (name, profile) => {
251
+ const client = await attachClient({
252
+ env, leaseToken: `ours-fleet-identity-create-${process.pid}-${randomUUID()}`,
253
+ clientPid: process.pid,
254
+ });
255
+ if (!client.listIdentities || !client.createIdentity || !client.releaseLease)
256
+ throw new Error('the selected ours SDK client cannot create permanent identities');
257
+ try {
258
+ const before = await client.listIdentities();
259
+ const existing = before.find(identity => identity.name === name);
260
+ if (existing) {
261
+ if (existing.temp)
262
+ throw new Error(`identity '${name}' exists but is temporary; refusing to convert or adopt it`);
263
+ return; // another reconciler won the create race
264
+ }
265
+ if (!before.some(identity => identity.kind === 'root'))
266
+ throw new Error(`cannot create role identity '${name}': this host has no Human identity; run ours onboarding first`);
267
+ let createdHere = false;
268
+ try {
269
+ await client.createIdentity({
270
+ name,
271
+ bio: profile.bio ?? '',
272
+ exposeLocal: profile.exposeLocal ?? true,
273
+ localAutoAccept: profile.localAutoAccept ?? true,
274
+ });
275
+ createdHere = true;
276
+ }
277
+ catch (error) {
278
+ // Creation is daemon-atomic. If a concurrent reconciler won by name,
279
+ // accept only the compatible permanent identity now visible.
280
+ const after = await client.listIdentities();
281
+ const raced = after.find(identity => identity.name === name);
282
+ if (!raced || raced.temp)
283
+ throw error;
284
+ }
285
+ if (createdHere && profile.persona && client.setPersona)
286
+ await client.setPersona({ persona: profile.persona });
287
+ }
288
+ finally {
289
+ // createIdentity binds this lease. The managed role/channel is the next
290
+ // owner, so never leave the short-lived provisioning client holding it.
291
+ await client.releaseLease();
292
+ }
293
+ };
294
+ return provisioner;
295
+ }
296
+ /** Inventory-only variant for session-owned temporary identity lifecycles. */
297
+ export function daemonIdentityInventoryProvisioner(env = process.env, attachClient = attachOursClient) {
298
+ return daemonIdentityProvisioner(env, attachClient, {
299
+ createPermanent: false,
300
+ acceptTemporaryExisting: true,
301
+ });
215
302
  }
216
303
  /** Atomically write a role's fleet.d file, journalling it for rollback. */
217
304
  export function writeRoleFile(tx, file, contents) {