@ours.network/fleet 1.2.0-nightly.37 → 1.2.0-nightly.38

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.37",
3
- "buildId": "4ab0c90048fe",
4
- "commit": "46569b7ed2c22fd25fd6a019a3755768c817046e",
2
+ "version": "1.2.0-nightly.38",
3
+ "buildId": "2eaafbd42c9d",
4
+ "commit": "86c7af960f9b2cfdbf4dcddee25bc7303e78780c",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-02T21:13:43.143Z",
6
+ "builtAt": "2026-10-02T21:39:35.791Z",
7
7
  "capabilities": [
8
8
  "workspace.enroll.preserve-profile-v1",
9
9
  "cowork.http-management-v1",
@@ -1,3 +1,4 @@
1
+ import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
1
2
  export interface WorkspacePayload {
2
3
  version: 1;
3
4
  appOrigin: string;
@@ -16,18 +17,24 @@ export interface WorkspacePayload {
16
17
  };
17
18
  }
18
19
  export declare function readWorkspacePayload(file: string): WorkspacePayload;
20
+ type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
21
+ /** How tunnel setup reaches this host's daemon; replaced only by tests. */
22
+ export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
19
23
  /**
20
24
  * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
21
25
  * Reads only; an existing Owner is compared with this host's own Messenger identity.
22
26
  */
23
- export declare function checkWorkspaceConfiguration(configuration?: string): Promise<void>;
27
+ export declare function checkWorkspaceConfiguration(configuration?: string, attach?: AttachDaemonClient): Promise<void>;
24
28
  export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
25
29
  migrateAppOrigin?: boolean;
26
30
  preserveProfile?: boolean;
31
+ attach?: AttachDaemonClient;
32
+ contactWaitMs?: number;
33
+ afterInvitation?: () => void;
27
34
  }): Promise<{
28
35
  origin: string;
29
36
  hostWorkspaceId: string;
30
- rootCid: any;
37
+ rootCid: string;
31
38
  }>;
32
39
  /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
33
40
  /**
@@ -35,7 +42,7 @@ export declare function enrollWorkspace(payload: WorkspacePayload, configuration
35
42
  * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
36
43
  * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
37
44
  */
38
- export declare function removeEnrollmentContact(): Promise<boolean>;
45
+ export declare function removeEnrollmentContact(attach?: AttachDaemonClient): Promise<boolean>;
39
46
  export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
40
47
  /**
41
48
  * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
@@ -43,3 +50,4 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
43
50
  * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
44
51
  */
45
52
  export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
53
+ export {};
@@ -5,7 +5,9 @@ import { readClientProfile } from './client-profile.js';
5
5
  import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
6
  import { replaceFileAtomically } from './atomic-file.js';
7
7
  import { loadConfig, splitRootFor } from './config.js';
8
- import { parse, stringify } from 'yaml';
8
+ import { parse, parseDocument, stringify } from 'yaml';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { attachOursClient } from '@ours.network/sdk/client';
9
11
  import { preflightInitPaths } from './init-wizard.js';
10
12
  import { WebAccessStore } from './web/access.js';
11
13
  import { WorkspaceDeviceStore } from './web/workspace-devices.js';
@@ -71,14 +73,105 @@ function hostMessenger() {
71
73
  const secret = readFileSync(profile.credentialPath, 'utf8').trim();
72
74
  return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
73
75
  await response.body?.cancel();
74
- throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
76
+ throw Object.assign(Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying'), { status: response.status });
75
77
  } return response.json(); };
76
78
  }
79
+ const daemon = (attach, purpose) => { const profile = readClientProfile(); return attach({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId, credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `ours-fleet-workspace-${purpose}-${process.pid}-${randomUUID()}`, env: {} }); };
80
+ const CID = /^[a-f0-9]{64}$/i, same = (a, b) => a.toLowerCase() === b.toLowerCase();
81
+ /**
82
+ * Who this host's Messenger is. Messenger either runs as the Human root itself, or as the person's own
83
+ * identity and then names the root the daemon describes for it. That is accepted only when this host's
84
+ * daemon, asked by Fleet itself, lists exactly one Human root, that root is the named one, and the
85
+ * Messenger identity is listed as a permanent identity under it.
86
+ */
87
+ async function messengerIdentity(request, attach) {
88
+ const bound = await request('workspace/enrollment-identity');
89
+ if (bound.rootCid === undefined)
90
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true };
91
+ if (typeof bound.cid !== 'string' || !CID.test(bound.cid) || typeof bound.rootCid !== 'string' || !CID.test(bound.rootCid) || same(bound.cid, bound.rootCid))
92
+ throw Error('Bound Messenger identity unavailable');
93
+ const root = await humanRoot(attach, bound.rootCid, bound.cid);
94
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, root };
95
+ }
96
+ /** This host's only Human root, as its daemon lists it; optionally with a permanent identity that must be listed under it. */
97
+ async function humanRoot(attach, rootCid, under) {
98
+ const client = await daemon(attach, 'identities');
99
+ const rows = await client.listIdentities().finally(() => client.close());
100
+ const roots = rows.filter(row => 'kind' in row && row.kind === 'root'), root = roots[0], row = under === undefined ? undefined : rows.find(row => 'cid' in row && same(row.cid, under));
101
+ if (roots.length !== 1 || !('kind' in root) || !same(root.cid, rootCid) || (under !== undefined && (!row || !('kind' in row) || row.kind !== 'role' || row.temp !== null)))
102
+ throw Error("This host's Messenger identity is not a permanent identity under this host's Human root. Tunnel setup changed nothing.");
103
+ return { name: root.name, cid: root.cid };
104
+ }
105
+ /** Run one operation as the Human root and always give the root back, whatever happens. */
106
+ async function asHumanRoot(attach, root, purpose, use) {
107
+ const client = await daemon(attach, purpose);
108
+ let done = false;
109
+ try {
110
+ const bound = await client.chooseIdentity({ name: root.name, force: false }).catch((error) => {
111
+ throw Error(`This host's Human root cannot be used right now (${error.code ?? error.message}). Close the session that holds it, then run setup again. Nothing was changed.`);
112
+ });
113
+ if (!same(bound.cid, root.cid))
114
+ throw Error("This host's Human root changed while setup was running. Nothing was changed.");
115
+ const result = await use(client);
116
+ // The operation is not reported as done while the root may still be held. The release is attempted exactly once.
117
+ done = true;
118
+ const released = await client.releaseLease();
119
+ if (released.failed > 0)
120
+ throw Error("This host's Human root was not given back completely. Run setup again.");
121
+ return result;
122
+ }
123
+ finally {
124
+ // After a failure the root is released once as well; what failed first is what is reported.
125
+ if (!done)
126
+ await client.releaseLease().catch(() => { });
127
+ await client.close().catch(() => { });
128
+ }
129
+ }
130
+ /** The signed workspace binding, sent by the Human root itself: the same command Messenger sends when it runs as the root. */
131
+ async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs) {
132
+ const serverCid = payload.serverCid.toUpperCase();
133
+ await asHumanRoot(attach, root, 'proof', async (client) => {
134
+ const peer = await client.addContact({ invite: payload.invitation });
135
+ if (peer.cid.toUpperCase() !== serverCid)
136
+ throw Error('Enrollment server identity mismatch');
137
+ const deadline = Date.now() + contactWaitMs;
138
+ while (!(await client.listContacts()).contacts.some(contact => contact.container_id.toUpperCase() === serverCid)) {
139
+ if (Date.now() >= deadline)
140
+ throw Error('Enrollment contact is not ready; obtain a fresh setup payload before retrying');
141
+ await new Promise(resolve => setTimeout(resolve, 100));
142
+ }
143
+ const outcome = await client.sendCommand({ contact: serverCid, command: 'bind-workspace', arguments: { type: 'ours.app.bind-workspace.v1', accountId: payload.challenge.accountId, workspaceId: payload.challenge.workspaceId, nonce: payload.challenge.nonce, hostWorkspaceId } });
144
+ if (!('sent' in outcome) || !outcome.sent)
145
+ throw Error('Workspace proof was not sent');
146
+ });
147
+ }
148
+ /**
149
+ * True when the Owner entry is the one tunnel setup manages and it still names the Human root of an
150
+ * installation whose Messenger now runs as the person's own identity under that root. Only then is the
151
+ * entry moved to that identity; an Owner entry anyone else wrote is never touched.
152
+ */
153
+ function managedRootOwner(configuration, rooms, root) {
154
+ if (!same(rooms.owner.expected_cid, root.cid) || rooms.owner.public_invite_file !== workspaceOwnerInvite() || rooms.defaults?.attach_owner === false)
155
+ return false;
156
+ if (!existsSync(join(stateRoot(), 'workspace', 'binding.json')))
157
+ return false;
158
+ let written;
159
+ try {
160
+ written = parseDocument(readFileSync(configuration, 'utf8')).toJS()?.rooms;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ const entry = written?.owner, defaults = written?.defaults, keys = (value) => Object.keys(value).sort().join();
166
+ // Exactly the Owner entry and defaults tunnel setup writes; anything added or changed beside them is someone's own configuration.
167
+ return !!written && keys(written) === 'defaults,owner' && !!entry && keys(entry) === 'expected_cid,provider,public_invite_file,role' && entry.provider === 'messenger-server' && entry.role === 'Owner' && typeof entry.expected_cid === 'string' && same(entry.expected_cid, root.cid)
168
+ && !!defaults && keys(defaults) === 'attach_owner,close_when_task_done' && defaults.attach_owner === true && defaults.close_when_task_done === true;
169
+ }
77
170
  /**
78
171
  * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
79
172
  * Reads only; an existing Owner is compared with this host's own Messenger identity.
80
173
  */
81
- export async function checkWorkspaceConfiguration(configuration = defaultConfigPath()) {
174
+ export async function checkWorkspaceConfiguration(configuration = defaultConfigPath(), attach = attachOursClient) {
82
175
  const paths = preflightInitPaths(configuration);
83
176
  if (!paths.manifestExisted) {
84
177
  if (paths.rootExisted)
@@ -92,19 +185,20 @@ export async function checkWorkspaceConfiguration(configuration = defaultConfigP
92
185
  throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
93
186
  if (!loaded.rooms.owner.public_invite_file)
94
187
  throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
95
- let cid;
188
+ let cid, root;
96
189
  try {
97
- cid = (await hostMessenger()('workspace/enrollment-identity')).cid;
190
+ ({ cid, root } = await messengerIdentity(hostMessenger(), attach));
98
191
  }
99
192
  catch {
100
193
  cid = undefined;
101
194
  }
102
195
  if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
103
196
  throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
104
- if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath))
197
+ if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath) && !(root && managedRootOwner(paths.configPath, loaded.rooms, root)))
105
198
  throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
106
199
  }
107
200
  export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
201
+ const attach = options.attach ?? attachOursClient;
108
202
  const request = hostMessenger();
109
203
  if (new WebAccessStore().read().mode === 'none')
110
204
  throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
@@ -131,12 +225,15 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
131
225
  throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
132
226
  if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
133
227
  throw Error('Existing room owner configuration must be reviewed before workspace enrollment');
134
- const identity = await request('workspace/enrollment-identity');
228
+ const identity = await messengerIdentity(request, attach);
135
229
  if (!/^[a-f0-9]{64}$/i.test(identity.cid))
136
230
  throw Error('Bound Messenger identity unavailable');
231
+ // An installation that ran Messenger as the Human root and now runs it as the person's own identity keeps its managed Owner entry, moved to that identity.
232
+ const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
233
+ && JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase();
137
234
  if (options.preserveProfile && identity.preserveProfile !== true)
138
235
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
139
- if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
236
+ if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
140
237
  throw Error('Retained room owner CID conflicts with the Human root');
141
238
  const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
142
239
  if (existsSync(inviteFile)) {
@@ -148,14 +245,30 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
148
245
  const hostWorkspaceId = devices.workspaceId;
149
246
  devices.close();
150
247
  const { name, surname, ...proofPayload } = payload;
151
- const result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
152
- if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
153
- throw Error('Malformed workspace enrollment response');
154
- // Whatever the configuration said before, what is recorded from here on is this host's own root.
155
- if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
156
- throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
157
- if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
158
- throw Error('Retained room owner CID conflicts with the Human root');
248
+ let result, ownerCid;
249
+ if (identity.root) {
250
+ // The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
251
+ await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000);
252
+ const invite = await request('invites', { mode: 'public' });
253
+ if (typeof invite.blob !== 'string' || !invite.blob)
254
+ throw Error('Malformed workspace enrollment response');
255
+ const after = await request('workspace/enrollment-identity');
256
+ if (typeof after.cid !== 'string' || !same(after.cid, identity.cid) || typeof after.rootCid !== 'string' || !same(after.rootCid, identity.root.cid))
257
+ throw Error("This host's Messenger identity changed while setup was running; local configuration is unchanged");
258
+ result = { submitted: true, rootCid: identity.root.cid, ownerInvite: invite.blob };
259
+ ownerCid = identity.cid;
260
+ }
261
+ else {
262
+ result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
263
+ if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
264
+ throw Error('Malformed workspace enrollment response');
265
+ // Whatever the configuration said before, what is recorded from here on is this host's own root.
266
+ if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
267
+ throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
268
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
269
+ throw Error('Retained room owner CID conflicts with the Human root');
270
+ ownerCid = result.rootCid;
271
+ }
159
272
  if (migrating) {
160
273
  const deadline = Date.now() + 15000;
161
274
  while (true) {
@@ -173,16 +286,24 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
173
286
  }
174
287
  }
175
288
  replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
289
+ options.afterInvitation?.();
176
290
  const split = splitRootFor(configuration);
177
291
  mkdirSync(split, { recursive: true, mode: 0o700 });
178
292
  if (!loaded.rooms)
179
- replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
180
- else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
181
- replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
293
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
294
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
295
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
296
+ else if (moved) {
297
+ // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
298
+ // which is this same case again: running setup once more writes both.
299
+ const document = parseDocument(readFileSync(configuration, 'utf8'));
300
+ document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
301
+ replaceFileAtomically(configuration, document.toString(), 0o600);
302
+ }
182
303
  replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
183
304
  const origin = `https://${payload.hostname}`;
184
305
  replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
185
- replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase() }) + '\n', 0o600);
306
+ replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
186
307
  loadConfig(configuration);
187
308
  return { origin, hostWorkspaceId, rootCid: result.rootCid };
188
309
  }
@@ -192,18 +313,30 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
192
313
  * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
193
314
  * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
194
315
  */
195
- export async function removeEnrollmentContact() {
196
- let serverCid;
316
+ export async function removeEnrollmentContact(attach = attachOursClient) {
317
+ let serverCid, proofRootCid;
197
318
  try {
198
- serverCid = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')).serverCid;
319
+ ({ serverCid, proofRootCid } = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')));
199
320
  }
200
321
  catch {
201
322
  return false;
202
323
  }
203
324
  if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
204
325
  return false;
326
+ const contact = serverCid.toUpperCase();
327
+ // The contact belongs to whoever sent the binding. When that was the Human root itself, the binding records it.
328
+ if (typeof proofRootCid === 'string' && CID.test(proofRootCid)) {
329
+ try {
330
+ const root = await humanRoot(attach, proofRootCid);
331
+ await asHumanRoot(attach, root, 'cleanup', client => client.removeContact({ contact }));
332
+ }
333
+ catch {
334
+ throw Error('the Human root did not remove it');
335
+ }
336
+ return true;
337
+ }
205
338
  try {
206
- await hostMessenger()('contacts/remove', { contact: serverCid.toUpperCase() });
339
+ await hostMessenger()('contacts/remove', { contact });
207
340
  }
208
341
  catch {
209
342
  throw Error('Messenger did not remove it');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.37",
3
+ "version": "1.2.0-nightly.38",
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",