@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.
- package/dist/account-origin.d.ts +4 -1
- package/dist/account-origin.js +27 -2
- package/dist/application/conversation-history.d.ts +7 -0
- package/dist/application/conversation-history.js +65 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/resource-observer.d.ts +40 -0
- package/dist/application/resource-observer.js +278 -0
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/role-repository.d.ts +1 -1
- package/dist/application/role-repository.js +11 -5
- package/dist/application/task-layouts.js +2 -0
- package/dist/application/task-room-service.d.ts +2 -0
- package/dist/application/task-room-service.js +2 -2
- package/dist/build-info.json +4 -4
- package/dist/daemon-recovery.d.ts +13 -1
- package/dist/daemon-recovery.js +117 -3
- package/dist/monitor.d.ts +1 -0
- package/dist/monitor.js +1 -1
- package/dist/notifications/fleet-producer.d.ts +4 -1
- package/dist/notifications/fleet-producer.js +39 -10
- package/dist/notifications/outbox.d.ts +14 -1
- package/dist/notifications/outbox.js +51 -10
- package/dist/notifications/target-cleanup.d.ts +10 -4
- package/dist/notifications/target-cleanup.js +35 -23
- package/dist/notifications/task-producer.d.ts +3 -1
- package/dist/notifications/task-producer.js +38 -5
- package/dist/ops.js +1 -1
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +3 -0
- package/dist/rooms-tasks/add-member.js +2 -1
- package/dist/rooms-tasks/layout.js +2 -1
- package/dist/rooms-tasks/task-state.d.ts +1 -0
- package/dist/rooms-tasks/task-state.js +27 -11
- package/dist/rooms-tasks/types.d.ts +4 -1
- package/dist/runner.js +22 -7
- package/dist/subscriptions/login.d.ts +4 -3
- package/dist/subscriptions/login.js +14 -6
- package/dist/subscriptions/service.d.ts +1 -1
- package/dist/subscriptions/service.js +4 -4
- package/dist/subscriptions/store.js +3 -0
- package/dist/tunnel-setup-input.js +7 -1
- package/dist/watchdog/query.d.ts +3 -1
- package/dist/watchdog/query.js +2 -1
- package/dist/web/auth.d.ts +2 -2
- package/dist/web/auth.js +4 -4
- package/dist/web/events.d.ts +2 -0
- package/dist/web/events.js +9 -0
- package/dist/web/prefix-gateway.js +20 -10
- package/dist/web/resource-views.d.ts +124 -0
- package/dist/web/resource-views.js +424 -0
- package/dist/web/runtime.js +6 -3
- package/dist/web/server.d.ts +3 -0
- package/dist/web/server.js +171 -20
- package/dist/workspace-enrollment.js +53 -28
- package/dist/workspace-replacement.js +28 -10
- package/docs/conversation-stream.md +6 -0
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
398
|
-
|
|
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 =
|
|
13
|
+
const replacementPath = workspaceReplacementFile;
|
|
14
14
|
const opaque = /^[\w-]{43}$/;
|
|
15
15
|
function binding(value) {
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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' &&
|
|
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
|
-
|
|
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.
|
|
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",
|