@ours.network/fleet 1.2.0-nightly.70 → 1.2.0-nightly.72

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 (58) hide show
  1. package/dist/account-origin.d.ts +4 -1
  2. package/dist/account-origin.js +27 -2
  3. package/dist/application/conversation-history.d.ts +7 -0
  4. package/dist/application/conversation-history.js +65 -0
  5. package/dist/application/fleet-query-service.d.ts +1 -1
  6. package/dist/application/fleet-query-service.js +2 -2
  7. package/dist/application/resource-observer.d.ts +40 -0
  8. package/dist/application/resource-observer.js +278 -0
  9. package/dist/application/role-removal-service.js +1 -1
  10. package/dist/application/role-repository.d.ts +1 -1
  11. package/dist/application/role-repository.js +11 -5
  12. package/dist/application/task-layouts.js +2 -0
  13. package/dist/application/task-room-service.d.ts +2 -0
  14. package/dist/application/task-room-service.js +2 -2
  15. package/dist/build-info.json +4 -4
  16. package/dist/daemon-recovery.d.ts +13 -1
  17. package/dist/daemon-recovery.js +117 -3
  18. package/dist/monitor.d.ts +1 -0
  19. package/dist/monitor.js +1 -1
  20. package/dist/notifications/fleet-producer.d.ts +4 -1
  21. package/dist/notifications/fleet-producer.js +39 -10
  22. package/dist/notifications/outbox.d.ts +14 -1
  23. package/dist/notifications/outbox.js +51 -10
  24. package/dist/notifications/target-cleanup.d.ts +10 -4
  25. package/dist/notifications/target-cleanup.js +35 -23
  26. package/dist/notifications/task-producer.d.ts +3 -1
  27. package/dist/notifications/task-producer.js +38 -5
  28. package/dist/ops.js +1 -1
  29. package/dist/paths.d.ts +3 -0
  30. package/dist/paths.js +3 -0
  31. package/dist/rooms-tasks/add-member.js +2 -1
  32. package/dist/rooms-tasks/layout.js +2 -1
  33. package/dist/rooms-tasks/task-state.d.ts +1 -0
  34. package/dist/rooms-tasks/task-state.js +27 -11
  35. package/dist/rooms-tasks/types.d.ts +4 -1
  36. package/dist/runner.js +22 -7
  37. package/dist/subscriptions/login.d.ts +4 -3
  38. package/dist/subscriptions/login.js +14 -6
  39. package/dist/subscriptions/service.d.ts +1 -1
  40. package/dist/subscriptions/service.js +4 -4
  41. package/dist/subscriptions/store.js +3 -0
  42. package/dist/tunnel-setup-input.js +7 -1
  43. package/dist/watchdog/query.d.ts +3 -1
  44. package/dist/watchdog/query.js +2 -1
  45. package/dist/web/auth.d.ts +2 -2
  46. package/dist/web/auth.js +4 -4
  47. package/dist/web/events.d.ts +2 -0
  48. package/dist/web/events.js +9 -0
  49. package/dist/web/prefix-gateway.js +20 -10
  50. package/dist/web/resource-views.d.ts +124 -0
  51. package/dist/web/resource-views.js +424 -0
  52. package/dist/web/runtime.js +6 -3
  53. package/dist/web/server.d.ts +3 -0
  54. package/dist/web/server.js +171 -20
  55. package/dist/workspace-enrollment.js +53 -28
  56. package/dist/workspace-replacement.js +28 -10
  57. package/docs/conversation-stream.md +6 -0
  58. package/package.json +1 -1
@@ -1,3 +1,7 @@
1
+ import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor } from './resource-views.js';
2
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
+ import { stateRoot, defaultConfigPath } from '../paths.js';
4
+ import { conversationPreview } from '../application/conversation-history.js';
1
5
  import { registerConversationStream } from './conversation-stream.js';
2
6
  import { conversationHasAttachment } from '../application/conversation-history.js';
3
7
  import { readDeliveredFile } from '../file-delivery/store.js';
@@ -49,7 +53,7 @@ export async function buildWebServer(services, boundary, options = {}) {
49
53
  reply.header('Content-Security-Policy', "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
50
54
  if (request.url.startsWith('/api/'))
51
55
  reply.header('Cache-Control', 'no-store');
52
- if (request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
56
+ if (auth.appOrigin && request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
53
57
  reply.header('Access-Control-Allow-Origin', auth.appOrigin).header('Vary', 'Origin');
54
58
  reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
55
59
  reply.header('Access-Control-Expose-Headers', 'X-Ours-Workspace-Auth, X-Ours-Request-Id');
@@ -61,6 +65,12 @@ export async function buildWebServer(services, boundary, options = {}) {
61
65
  }
62
66
  auth.validateBoundary(request, false);
63
67
  });
68
+ app.addHook('onResponse', async (request, reply) => {
69
+ if (reply.statusCode < 400 && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !request.url.includes('/resource-subscriptions')) {
70
+ publishFleetResourceCommit(services.configuration?.path ?? defaultConfigPath());
71
+ publishFleetResourceCommit(stateRoot());
72
+ }
73
+ });
64
74
  app.post('/api/v1/layout-bindings/:id/control', async (request) => {
65
75
  // Initial support is direct same-host Fleet-to-Fleet transport.
66
76
  const forwarded = Object.keys(request.headers).some(name => name === 'forwarded' || name.startsWith('x-forwarded-') || name === 'x-real-ip' || name === 'via');
@@ -74,6 +84,8 @@ export async function buildWebServer(services, boundary, options = {}) {
74
84
  return services.layoutBindings.control(request.params.id, request.headers.authorization, request.body);
75
85
  });
76
86
  app.setErrorHandler(async (error, request, reply) => {
87
+ if (error instanceof ResourceBindingError)
88
+ return reply.code(error.status).send({ error: { code: 'resource_binding', message: error.message } });
77
89
  const privateToolParseError = ['/api/v1/roles/:id/ours/call', '/api/v1/layout-bindings/:id/control'].includes(request.routeOptions.url ?? '')
78
90
  && error instanceof Error && 'code' in error
79
91
  && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
@@ -183,8 +195,10 @@ export async function buildWebServer(services, boundary, options = {}) {
183
195
  auditDegraded: audit.degraded,
184
196
  };
185
197
  });
198
+ let onboardingObserved;
186
199
  app.get('/api/v1/onboarding', async (request) => {
187
- auth.authenticate(request);
200
+ const browser = auth.authenticate(request);
201
+ const revision = services.configuration?.read().revision;
188
202
  // Opening an existing workspace needs configuration state, not CLI/account discovery.
189
203
  // Keep the full report for the setup screens and older App clients.
190
204
  if (request.query.view === 'state')
@@ -202,7 +216,12 @@ export async function buildWebServer(services, boundary, options = {}) {
202
216
  // When it could not be asked, none is offered and the reason is reported.
203
217
  const codexModels = services.codexModels ? await services.codexModels() : undefined;
204
218
  const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
205
- return { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
219
+ const payload = { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
220
+ if (revision === services.configuration?.read().revision) {
221
+ onboardingObserved = { owner: browser.id, revision, payload };
222
+ events.publish('onboarding.changed', {});
223
+ }
224
+ return payload;
206
225
  });
207
226
  // First-time setup is what creates the persistent coordinator. A configuration that loads and
208
227
  // has it is set up, whatever its brains are called; its models are then changed by editing it.
@@ -283,6 +302,9 @@ export async function buildWebServer(services, boundary, options = {}) {
283
302
  app.get('/api/v1/roles', async (request) => {
284
303
  auth.authenticate(request);
285
304
  const query = request.query;
305
+ // Unbound REST retains the existing problem-bearing inventory. Bound reads
306
+ // are handled by the strict resource preHandler before this route.
307
+ services.invalidateResourceCaches?.();
286
308
  return { roles: await services.query.list(query.includeTemporary === 'true') };
287
309
  });
288
310
  const taskApi = async (fn) => {
@@ -309,9 +331,152 @@ export async function buildWebServer(services, boundary, options = {}) {
309
331
  throw new FleetError('capability_unavailable', 'task operations are unavailable');
310
332
  return services.taskRooms;
311
333
  };
334
+ const projectTasks = async (query, strict = true) => {
335
+ const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
336
+ if (query.state && query.state !== 'all' && !states.includes(query.state))
337
+ throw new FleetError('invalid_request', 'invalid task state filter');
338
+ if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
339
+ throw new FleetError('invalid_request', 'groupByList must be true or false');
340
+ if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
341
+ throw new FleetError('invalid_request', 'includeDeleting must be true or false');
342
+ const state = query.state && query.state !== 'all' ? query.state : undefined;
343
+ const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
344
+ ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
345
+ const api = requireTaskRooms();
346
+ return taskApi(() => query.groupByList === 'true'
347
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
348
+ : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
349
+ };
350
+ const project = async (descriptor, signal, owner = '') => {
351
+ signal.throwIfAborted();
352
+ services.invalidateResourceCaches?.();
353
+ switch (descriptor.resource) {
354
+ case 'task-layout':
355
+ try {
356
+ return { layout: await taskApi(() => requireTaskRooms().taskLayout(descriptor.entityId)) };
357
+ }
358
+ catch (error) {
359
+ if (error?.code === 'task_not_found' || error?.code === 'resource_not_found')
360
+ return { deleted: true, id: descriptor.entityId };
361
+ throw error;
362
+ }
363
+ case 'creation-action': return services.creation.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
364
+ case 'action': return services.commands.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
365
+ case 'member-addition':
366
+ try {
367
+ return await taskApi(() => requireTaskRooms().memberAddition(descriptor.entityId, descriptor.receiptId));
368
+ }
369
+ catch (error) {
370
+ if (['task_not_found', 'resource_not_found'].includes(error?.code) || error?.message === 'Member request not found')
371
+ return { deleted: true, id: descriptor.receiptId };
372
+ throw error;
373
+ }
374
+ case 'login':
375
+ // A foreign login must never be delivered, including its URL/device code.
376
+ try {
377
+ return subscriptions().login(descriptor.entityId, owner).view();
378
+ }
379
+ catch (error) {
380
+ if (error?.code === 'resource_not_found')
381
+ return { deleted: true, id: descriptor.entityId };
382
+ throw error;
383
+ }
384
+ case 'subscriptions': return { providers: await subscriptions().list(true) };
385
+ case 'onboarding-health': {
386
+ const revision = services.configuration?.read().revision;
387
+ if (onboardingObserved && onboardingObserved.revision !== revision)
388
+ onboardingObserved = undefined;
389
+ const observed = onboardingObserved?.owner === owner ? onboardingObserved : undefined;
390
+ return { ...observed?.payload, observationCurrent: !!observed, configured: onboardingConfigured(), providers: services.subscriptions ? await services.subscriptions.list(true) : [] };
391
+ }
392
+ case 'roles': return { roles: await services.query.list(descriptor.includeTemporary ?? false, true) };
393
+ case 'tasks': return projectTasks({ state: descriptor.state, list: descriptor.list, groupByList: String(descriptor.groupByList ?? false), includeDeleting: String(descriptor.includeDeleting ?? false) });
394
+ case 'task-lists': return { lists: requireTaskRooms().listTaskLists() };
395
+ case 'configuration':
396
+ if (!services.configuration)
397
+ throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
398
+ return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
399
+ case 'directory': {
400
+ const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
401
+ return { roles, tasks: tasks.tasks, lists: requireTaskRooms().listTaskLists() };
402
+ }
403
+ case 'previews': {
404
+ const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
405
+ const previews = {};
406
+ for (const id of descriptor.ids ?? []) {
407
+ signal.throwIfAborted();
408
+ const role = selected.get(id), dir = role && services.repository.stateDir(role);
409
+ if (!role)
410
+ continue;
411
+ try {
412
+ previews[id] = dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
413
+ }
414
+ catch {
415
+ signal.throwIfAborted();
416
+ // A damaged/unreadable ledger is unavailable, not empty or removed.
417
+ // Keep this selected row observable so a repair can refresh it,
418
+ // without retiring previews for the other selected roles.
419
+ previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
420
+ }
421
+ }
422
+ return { previews };
423
+ }
424
+ }
425
+ };
426
+ const resources = new FleetResourceViews(services.resourceScope ?? {}, project);
427
+ app.addHook('preClose', async () => resources.close());
428
+ app.get('/api/v1/resource-events', async (request, reply) => {
429
+ const browser = auth.authenticate(request);
430
+ reply.hijack();
431
+ reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
432
+ reply.raw.flushHeaders();
433
+ const opened = resources.open(reply.raw, browser.id, () => auth.sessionValid(browser));
434
+ const binding = auth.bindTransport(browser, opened.close);
435
+ reply.raw.once('close', () => binding());
436
+ });
437
+ app.post('/api/v1/resource-subscriptions', async (request, reply) => {
438
+ const browser = auth.authenticate(request, true), body = request.body;
439
+ const binding = resources.register(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch, body?.descriptor);
440
+ const retire = () => {
441
+ if (reply.raw.writableFinished)
442
+ return;
443
+ try {
444
+ resources.remove(browser.id, String(body.connectionId), String(body.viewId), body.subscriptionEpoch);
445
+ }
446
+ catch { /* Connection may already be closed. */ }
447
+ };
448
+ reply.raw.once('close', retire);
449
+ reply.raw.once('finish', () => reply.raw.off('close', retire));
450
+ if (reply.raw.destroyed)
451
+ retire();
452
+ return binding;
453
+ });
454
+ app.delete('/api/v1/resource-subscriptions', async (request) => {
455
+ const browser = auth.authenticate(request, true), body = request.body;
456
+ resources.remove(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch);
457
+ return { removed: true };
458
+ });
459
+ // Bound reads return precisely the registered initial capture, never an unrelated GET.
460
+ app.addHook('preHandler', async (request, reply) => {
461
+ if (request.method !== 'GET' || !request.headers['x-resource-connection'])
462
+ return;
463
+ const browser = auth.authenticate(request), headers = request.headers;
464
+ const baseline = await resources.bound(browser.id, String(headers['x-resource-connection']), String(headers['x-resource-view']), Number(headers['x-resource-epoch']), String(headers['x-resource-baseline']));
465
+ const expected = '/api/v1/' + baseline.descriptor.resource;
466
+ if (request.url.split('?')[0] !== expected)
467
+ throw new ResourceBindingError(409, 'resource route mismatch');
468
+ for (const name of ['Connection', 'View', 'Epoch', 'Baseline'])
469
+ reply.header('X-Resource-' + name, String(headers['x-resource-' + name.toLowerCase()]));
470
+ reply.header('X-Resource-Producer', baseline.producerGeneration).header('X-Resource-Revision', baseline.revision).header('X-Resource-Source-Generation', baseline.source.producerGeneration).header('X-Resource-Source-Position', baseline.source.position);
471
+ return reply.send(baseline.payload);
472
+ });
473
+ for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
474
+ app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
475
+ app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
476
+ app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const ids = String(request.query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids }), new AbortController().signal); });
312
477
  app.get('/api/v1/task-lists', async (request) => {
313
478
  auth.authenticate(request);
314
- return { lists: requireTaskRooms().listTaskLists() };
479
+ return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
315
480
  });
316
481
  app.post('/api/v1/task-lists', async (request, reply) => {
317
482
  auth.authenticate(request, true);
@@ -334,21 +499,7 @@ export async function buildWebServer(services, boundary, options = {}) {
334
499
  });
335
500
  app.get('/api/v1/tasks', async (request) => {
336
501
  auth.authenticate(request);
337
- const query = request.query;
338
- const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
339
- if (query.state && query.state !== 'all' && !states.includes(query.state))
340
- throw new FleetError('invalid_request', 'invalid task state filter');
341
- if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
342
- throw new FleetError('invalid_request', 'groupByList must be true or false');
343
- if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
344
- throw new FleetError('invalid_request', 'includeDeleting must be true or false');
345
- const state = query.state && query.state !== 'all' ? query.state : undefined;
346
- const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
347
- ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
348
- const api = requireTaskRooms();
349
- return taskApi(() => query.groupByList === 'true'
350
- ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
351
- : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
502
+ return projectTasks(request.query, false);
352
503
  });
353
504
  app.delete('/api/v1/tasks/:id', async (request, reply) => {
354
505
  auth.authenticate(request, true);
@@ -558,7 +709,7 @@ export async function buildWebServer(services, boundary, options = {}) {
558
709
  auth.authenticate(request);
559
710
  if (!services.configuration)
560
711
  throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
561
- return services.configuration.read(request.query.includeDefinitions === 'true');
712
+ return project({ viewId: 'read', resource: 'configuration', includeDefinitions: request.query.includeDefinitions === 'true' }, new AbortController().signal);
562
713
  });
563
714
  app.post('/api/v1/configuration/preview', async (request) => {
564
715
  auth.authenticate(request, true);
@@ -1,9 +1,9 @@
1
- import { validateAccountOrigin } from './account-origin.js';
1
+ import { validateAccountOrigin, validateConfiguredAccountOrigin, validateConfiguredWorkspaceHost } from './account-origin.js';
2
2
  import { lstatSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { readClientProfile } from './client-profile.js';
5
- import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
- import { replaceFileAtomically } from './atomic-file.js';
5
+ import { stateRoot, defaultConfigPath, workspaceOwnerInvite, workspaceBindingLock, workspaceReplacementFile } from './paths.js';
6
+ import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
7
7
  import { loadConfig, splitRootFor } from './config.js';
8
8
  import { parse, parseDocument, stringify } from 'yaml';
9
9
  import { randomUUID } from 'node:crypto';
@@ -25,7 +25,13 @@ export function decodeWorkspacePayload(input, options = {}) {
25
25
  catch {
26
26
  throw Error('Invalid workspace payload');
27
27
  }
28
- if (!p || typeof p !== 'object' || Array.isArray(p) || p.version !== 1 || !['https://app.ours.network', 'https://app.ours-tunnel.com'].includes(p.appOrigin) || !/^[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid) || !p.challenge || !['nonce', 'accountId', 'workspaceId'].every(k => /^[\w-]{43}$/.test(String(p.challenge[k]))) || (!options.allowExpired && p.challenge.expiresAt <= Date.now()) || !Number.isSafeInteger(p.challenge.expiresAt) || p.challenge.expiresAt > Date.now() + 16 * 60000)
28
+ try {
29
+ validateConfiguredAccountOrigin(p?.appOrigin);
30
+ }
31
+ catch {
32
+ throw Error('Workspace payload is invalid or expired');
33
+ }
34
+ if (!p || typeof p !== 'object' || Array.isArray(p) || p.version !== 1 || !validateConfiguredWorkspaceHost(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid) || !p.challenge || !['nonce', 'accountId', 'workspaceId'].every(k => /^[\w-]{43}$/.test(String(p.challenge[k]))) || (!options.allowExpired && p.challenge.expiresAt <= Date.now()) || !Number.isSafeInteger(p.challenge.expiresAt) || p.challenge.expiresAt > Date.now() + 16 * 60000)
29
35
  throw Error('Workspace payload is invalid or expired');
30
36
  for (const k of ['connectorToken', 'invitation']) {
31
37
  if (k === 'connectorToken' && p[k] === undefined)
@@ -58,7 +64,7 @@ export function checkWorkspaceConnector(payload) {
58
64
  catch {
59
65
  reject();
60
66
  }
61
- if (previous.workspaceId !== payload.challenge.workspaceId || validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network') !== payload.appOrigin || (previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase()))
67
+ if (previous.workspaceId !== payload.challenge.workspaceId || validateAccountOrigin(previous.appOrigin) !== payload.appOrigin || (previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase()))
62
68
  reject();
63
69
  try {
64
70
  const file = join(dir, 'connector'), stat = lstatSync(file);
@@ -264,11 +270,24 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
264
270
  const appOrigin = validateAccountOrigin(payload.appOrigin);
265
271
  const previousFile = join(dir, 'binding.json');
266
272
  let migrating = false;
267
- if (existsSync(previousFile)) {
268
- const previous = JSON.parse(readFileSync(previousFile, 'utf8'));
273
+ // Every decision below rests on this binding and on the unfinished replacement record (none, or the
274
+ // one this setup completes); both are rechecked under the binding lock before the first change and
275
+ // again at the final write, so a concurrent setup or replacement is never overwritten.
276
+ const read = (file) => existsSync(file) ? readFileSync(file, 'utf8') : null;
277
+ const priorBinding = read(previousFile), priorReplacement = read(workspaceReplacementFile());
278
+ if (priorReplacement !== null && options.replacingWorkspace === undefined)
279
+ throw Error('An unfinished replacement exists; resume or abandon it before setup. Existing setup preserved');
280
+ const unchangedBinding = () => {
281
+ if (read(previousFile) !== priorBinding)
282
+ throw Error('The workspace binding changed during setup; existing setup preserved. Run the setup command again');
283
+ if (read(workspaceReplacementFile()) !== priorReplacement)
284
+ throw Error('The unfinished replacement changed during setup; existing setup preserved. Review it with setup-tunnel --resume');
285
+ };
286
+ if (priorBinding !== null) {
287
+ const previous = JSON.parse(priorBinding);
269
288
  if (previous.workspaceId !== payload.challenge.workspaceId && options.replacingWorkspace !== previous.workspaceId)
270
289
  throw Error('This host is already associated with another workspace');
271
- const priorOrigin = validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network');
290
+ const priorOrigin = validateAccountOrigin(previous.appOrigin);
272
291
  migrating = priorOrigin !== appOrigin && options.replacingWorkspace !== previous.workspaceId;
273
292
  if (migrating && !options.migrateAppOrigin)
274
293
  throw Error('Account origin change requires explicit --migrate-app-origin and a fresh setup payload');
@@ -287,7 +306,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
287
306
  throw Error('Bound Messenger identity unavailable');
288
307
  // 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.
289
308
  const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
290
- && (options.replacingWorkspace !== undefined || JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
309
+ && (options.replacingWorkspace !== undefined || JSON.parse(priorBinding).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
291
310
  if (options.preserveProfile && identity.preserveProfile !== true)
292
311
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
293
312
  if (!options.preserveProfile && (payload.fullName?.trim() || !payload.name?.trim() || !payload.surname?.trim()) && !identity.optionalWorkspaceProfileV1)
@@ -327,6 +346,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
327
346
  throw Error('Invalid replacement proof receipt');
328
347
  }
329
348
  }
349
+ withSynchronousFileLock(workspaceBindingLock(), unchangedBinding);
330
350
  if (identity.root) {
331
351
  if (!options.preserveProfile)
332
352
  await request('identity/profile', { fullName: fullName ?? null, name: name ?? null, surname: surname ?? null });
@@ -376,26 +396,31 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
376
396
  await new Promise(resolve => setTimeout(resolve, 500));
377
397
  }
378
398
  }
379
- replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
380
- options.afterInvitation?.();
381
- const split = splitRootFor(configuration);
382
- mkdirSync(split, { recursive: true, mode: 0o700 });
383
- if (!loaded.rooms)
384
- replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
385
- else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
386
- replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
387
- else if (moved) {
388
- // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
389
- // which is this same case again: running setup once more writes both.
390
- const document = parseDocument(readFileSync(configuration, 'utf8'));
391
- document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
392
- replaceFileAtomically(configuration, document.toString(), 0o600);
393
- }
394
- if (payload.connectorToken !== undefined)
395
- replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
396
399
  const origin = `https://${payload.hostname}`;
397
- replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
398
- replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
400
+ // Local files commit together under the binding lock, after rechecking the binding this setup decided
401
+ // on, so a stale concurrent setup refuses before changing any of them.
402
+ withSynchronousFileLock(workspaceBindingLock(), () => {
403
+ unchangedBinding();
404
+ replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
405
+ options.afterInvitation?.();
406
+ const split = splitRootFor(configuration);
407
+ mkdirSync(split, { recursive: true, mode: 0o700 });
408
+ if (!loaded.rooms)
409
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
410
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
411
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
412
+ else if (moved) {
413
+ // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
414
+ // which is this same case again: running setup once more writes both.
415
+ const document = parseDocument(readFileSync(configuration, 'utf8'));
416
+ document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
417
+ replaceFileAtomically(configuration, document.toString(), 0o600);
418
+ }
419
+ if (payload.connectorToken !== undefined)
420
+ replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
421
+ replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
422
+ replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
423
+ });
399
424
  loadConfig(configuration);
400
425
  return { origin, hostWorkspaceId, rootCid: result.rootCid };
401
426
  }
@@ -3,20 +3,21 @@ import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { validateAccountOrigin } from './account-origin.js';
6
- import { replaceFileAtomically } from './atomic-file.js';
7
- import { stateRoot } from './paths.js';
6
+ import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
7
+ import { stateRoot, workspaceBindingLock, workspaceReplacementFile } from './paths.js';
8
8
  import { decodeWorkspacePayload, enrollWorkspace, unregisterWorkspace, checkWorkspaceConnector } from './workspace-enrollment.js';
9
9
  import { WorkspaceDeviceStore } from './web/workspace-devices.js';
10
10
  import { webControlPath } from './web/control.js';
11
11
  import { WebServiceManager } from './web/service.js';
12
12
  const workspaceDir = () => join(stateRoot(), 'workspace');
13
- const replacementPath = () => join(workspaceDir(), 'replacement.json');
13
+ const replacementPath = workspaceReplacementFile;
14
14
  const opaque = /^[\w-]{43}$/;
15
15
  function binding(value) {
16
- if (!value || !opaque.test(value.workspaceId) || !opaque.test(value.hostWorkspaceId) || (value.serverCid !== undefined && !/^[a-f0-9]{64}$/i.test(value.serverCid))
17
- || (value.proofRootCid !== undefined && !/^[a-f0-9]{64}$/i.test(value.proofRootCid)))
16
+ const text = (id, pattern) => typeof id === 'string' && pattern.test(id);
17
+ if (!value || typeof value !== 'object' || !text(value.workspaceId, opaque) || !text(value.hostWorkspaceId, opaque) || (value.serverCid !== undefined && !text(value.serverCid, /^[a-f0-9]{64}$/i))
18
+ || (value.proofRootCid !== undefined && !text(value.proofRootCid, /^[a-f0-9]{64}$/i)))
18
19
  throw Error('Existing workspace binding is invalid; restore it before tunnel setup');
19
- return { ...value, appOrigin: validateAccountOrigin(value.appOrigin ?? 'https://app.ours.network') };
20
+ return { ...value, appOrigin: validateAccountOrigin(value.appOrigin) };
20
21
  }
21
22
  export function readWorkspaceBinding() {
22
23
  const file = join(workspaceDir(), 'binding.json');
@@ -53,7 +54,7 @@ export async function confirmWorkspaceReplacement(previous, explicit = false, ef
53
54
  let hostname = '';
54
55
  try {
55
56
  const tunnel = JSON.parse(readFileSync(join(workspaceDir(), 'tunnel.json'), 'utf8'));
56
- if (typeof tunnel.origin === 'string' && /^https:\/\/[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(tunnel.origin))
57
+ if (typeof tunnel.origin === 'string' && validateAccountOrigin(tunnel.origin))
57
58
  hostname = ` (${new URL(tunnel.origin).hostname})`;
58
59
  }
59
60
  catch { }
@@ -84,7 +85,7 @@ export async function confirmWorkspaceReplacement(previous, explicit = false, ef
84
85
  return false;
85
86
  }
86
87
  export function saveWorkspaceReplacement(record) {
87
- replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600);
88
+ withSynchronousFileLock(workspaceBindingLock(), () => replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600));
88
89
  }
89
90
  export function readWorkspaceReplacement() {
90
91
  const file = replacementPath();
@@ -110,7 +111,7 @@ export function readWorkspaceReplacement() {
110
111
  record.payload = decodeWorkspacePayload(Buffer.from(JSON.stringify(record.payload)).toString('base64url'), { allowExpired: true });
111
112
  return record;
112
113
  }
113
- export function clearWorkspaceReplacement() { rmSync(replacementPath(), { force: true }); }
114
+ export function clearWorkspaceReplacement() { withSynchronousFileLock(workspaceBindingLock(), () => rmSync(replacementPath(), { force: true })); }
114
115
  /** Explicit recovery after the user removes an unsuccessful successor in the App. */
115
116
  export function discardRetiredWorkspaceReplacement() {
116
117
  const record = readWorkspaceReplacement(), current = readWorkspaceBinding();
@@ -132,9 +133,26 @@ export async function beginWorkspaceReplacement(previous, payload, configuration
132
133
  if (!requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, appOrigin: payload.appOrigin, serverCid: payload.serverCid }))
133
134
  throw Error('The same workspace must never be retired');
134
135
  checkReplacementService(configuration);
136
+ // The record is created only for the binding this decision was made on, and only if no other
137
+ // replacement or setup changed the binding or the record meanwhile; enrollment checks the same pair.
138
+ const bindingFile = join(workspaceDir(), 'binding.json'), current = () => existsSync(bindingFile) ? readFileSync(bindingFile, 'utf8') : null;
139
+ const unchanged = (bytes) => {
140
+ if (current() !== bytes)
141
+ throw Error('The workspace binding changed during setup; existing setup preserved. Run the setup command again');
142
+ if (existsSync(replacementPath()))
143
+ throw Error('Another replacement started during setup; existing setup preserved. Review it with setup-tunnel --resume');
144
+ };
145
+ const before = withSynchronousFileLock(workspaceBindingLock(), () => {
146
+ const bytes = current();
147
+ unchanged(bytes);
148
+ const now = readWorkspaceBinding();
149
+ if (!now || now.workspaceId !== previous.workspaceId || now.hostWorkspaceId !== previous.hostWorkspaceId || now.appOrigin !== previous.appOrigin || now.serverCid?.toUpperCase() !== previous.serverCid?.toUpperCase())
150
+ throw Error('The workspace binding changed during setup; existing setup preserved. Run the setup command again');
151
+ return bytes;
152
+ });
135
153
  const record = { version: 2, previous: previous, payload, configuration: resolve(configuration), operationNonce: randomBytes(32).toString('base64url'), dispatchAttempted: false, operationExpiresAt: Date.now() + 86400000 - 5 * 60000 };
136
154
  record.rootCid = await (effects.preflight ?? unregisterWorkspace)(record.previous, payload, record.operationNonce, effects.attach, { checkOnly: true, operationExpiresAt: record.operationExpiresAt });
137
- saveWorkspaceReplacement(record);
155
+ withSynchronousFileLock(workspaceBindingLock(), () => { unchanged(before); replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600); });
138
156
  return record;
139
157
  }
140
158
  /** Abandon only after a private receipt confirms no deletion and dispatch was unsent or terminally rejected. */
@@ -188,3 +188,9 @@ explicit recovery and unsupported-host fallback. DOM mutation and fixture latenc
188
188
  measurements have no claim about real-provider inference or physical-device audio.
189
189
  The App and Fleet changes require coordinated upstream delivery; source qualification
190
190
  does not establish that installed or serving packages contain them.
191
+
192
+ ## Full resource delivery qualification
193
+
194
+ Directory, configuration/definitions, previews, provider/status, layout and admitted-operation views share the authenticated ephemeral resource connection with per-view epochs. Complete ready requires the canonical subscribed payload; a heartbeat does not certify a failed upstream watcher. User commands remain HTTP. Controller, voice and selected-daemon health are independent owners. The selected-daemon health deadline passes its cancellation signal through the public SDK selection/request API, cancelling underlying HTTP I/O as well as retiring the result.
195
+
196
+ The matching App source qualification composes the existing ACP/Codex browser fixture and records sixty-second healthy windows separately from initial/history/recovery/command/health/lease/long-poll traffic. Unit and HTTP producer checks are not proof of every rendered resource combination or installed deployment. Preserve existing room-layout suite sources and qualify released service/dependency versions separately.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.70",
3
+ "version": "1.2.0-nightly.72",
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",