@ours.network/fleet 1.2.0-nightly.25 → 1.2.0-nightly.27

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.
@@ -0,0 +1,2 @@
1
+ export declare const accountOrigins: readonly ["https://app.ours.network", "https://app.ours-tunnel.com"];
2
+ export declare function validateAccountOrigin(value: unknown): string;
@@ -0,0 +1,6 @@
1
+ export const accountOrigins = ['https://app.ours.network', 'https://app.ours-tunnel.com'];
2
+ export function validateAccountOrigin(value) {
3
+ if (typeof value !== 'string' || !accountOrigins.some(origin => origin === value))
4
+ throw Error('Unsupported account origin');
5
+ return value;
6
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.25",
3
- "buildId": "a567036edf8d",
4
- "commit": "59ceeac2bb5c52f0861fe19d3c20e5e884d37c6c",
2
+ "version": "1.2.0-nightly.27",
3
+ "buildId": "7b8a6a05d7bf",
4
+ "commit": "4161f16e64c900a45963e483eb9783aeea6a8a27",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-30T20:02:41.728Z",
6
+ "builtAt": "2026-10-01T07:05:24.271Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import QRCode from 'qrcode';
3
+ import { readWorkspacePayload, enrollWorkspace } from './workspace-enrollment.js';
2
4
  import { waitForRoleDaemon } from './startup-readiness.js';
3
5
  import { SupervisorOursTools } from './application/supervisor-ours-tools.js';
4
6
  import { runTempSupervisor, TEMP_RECYCLE_EXIT } from './temp-supervisor-recovery.js';
@@ -39,7 +41,7 @@ import { controlRequest, controlSocketPath, followControl, livenessNote, } from
39
41
  import { SessionControlError } from './session/types.js';
40
42
  import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
41
43
  import { startWebConsole } from './web/runtime.js';
42
- import { requestWebControl } from './web/control.js';
44
+ import { requestWebControl, webControlPath } from './web/control.js';
43
45
  import { WebServiceManager } from './web/service.js';
44
46
  import { WebAccessStore, passwordAccess, validatePublicOrigin } from './web/access.js';
45
47
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
@@ -1527,6 +1529,46 @@ webCommand.command('open').description('securely open or re-pair a browser with
1527
1529
  die(e);
1528
1530
  }
1529
1531
  });
1532
+ cOpt(program.command('workspace-enroll').description('enroll this host using a private account setup file'))
1533
+ .requiredOption('--file <path>', 'owned private payload file')
1534
+ .option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
1535
+ .action(async (opts) => {
1536
+ try {
1537
+ const result = await enrollWorkspace(readWorkspacePayload(opts.file), opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin });
1538
+ if (!existsSync(new WebAccessStore().path))
1539
+ configureWebAccess({ pairing: true });
1540
+ const manager = new WebServiceManager();
1541
+ await manager.install(binPath, 49271, opts.configuration, { bind: '127.0.0.1', publicOrigin: result.origin });
1542
+ await manager.restart();
1543
+ const controlDeadline = Date.now() + 20000;
1544
+ while (!existsSync(webControlPath())) {
1545
+ if (Date.now() >= controlDeadline)
1546
+ throw Error('Workspace console is still starting; run ours-fleet link-device when ready');
1547
+ await new Promise(resolve => setTimeout(resolve, 200));
1548
+ }
1549
+ const link = await requestWebControl('link-device');
1550
+ const code = Buffer.from(JSON.stringify(link)).toString('base64url');
1551
+ process.stdout.write('Root proof submitted; account setup is ready only after tunnel health and binding verification.\nPrivate single-use device code:\n');
1552
+ process.stdout.write(await QRCode.toString(code, { type: 'terminal', small: true }));
1553
+ process.stdout.write('\n' + code + '\n');
1554
+ }
1555
+ catch (error) {
1556
+ die(error);
1557
+ }
1558
+ });
1559
+ program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
1560
+ .action(async () => {
1561
+ try {
1562
+ const link = await requestWebControl('link-device');
1563
+ process.stdout.write('Private connection code; share only with the intended device.\n');
1564
+ const code = Buffer.from(JSON.stringify(link)).toString('base64url');
1565
+ process.stdout.write(await QRCode.toString(code, { type: 'terminal', small: true }));
1566
+ process.stdout.write('\n' + code + '\n');
1567
+ }
1568
+ catch (e) {
1569
+ die(e);
1570
+ }
1571
+ });
1530
1572
  webCommand.command('revoke-all').description('revoke all trusted browsers and active web sessions')
1531
1573
  .action(async () => {
1532
1574
  try {
@@ -27,7 +27,7 @@ export async function taskLiveReadiness(task, room, cowork, deps = {}) {
27
27
  if (!remote || remote.room_id !== room.room_id || remote.identity_cid !== room.room_identity_cid
28
28
  || remote.state !== 'active')
29
29
  return degraded('room_identity_or_state_mismatch');
30
- if (room.owner_seat_cid && remote.seats.filter(seat => seat.identity_cid === room.owner_seat_cid && seat.seat_state === 'active').length !== 1)
30
+ if (room.owner_seat_cid && remote.seats.filter(seat => seat.identity_cid.toLowerCase() === room.owner_seat_cid.toLowerCase() && seat.seat_state === 'active').length !== 1)
31
31
  return degraded('owner_seat_missing');
32
32
  for (const seat of room.member_seats) {
33
33
  const member = seat.role_name;
@@ -80,6 +80,6 @@ export async function taskLiveReadiness(task, room, cowork, deps = {}) {
80
80
  const memberCids = new Set(room.member_seats.map(seat => seat.identity_cid));
81
81
  const memberRoles = new Set(room.member_seats.map(seat => seat.cowork_role));
82
82
  if (remote.seats.some(seat => seat.seat_state === 'active' && memberRoles.has(seat.role)
83
- && !memberCids.has(seat.identity_cid) && seat.identity_cid !== room.owner_seat_cid))
83
+ && !memberCids.has(seat.identity_cid) && seat.identity_cid.toLowerCase() !== room.owner_seat_cid?.toLowerCase()))
84
84
  return degraded('untracked_member_seats');
85
85
  }
@@ -619,7 +619,7 @@ export async function reconcileExistingTaskMembers(input) {
619
619
  || remote.state !== 'active')
620
620
  throw new Error('Cowork room is not the exact active room');
621
621
  assertCoworkRoomPolicy(remote, storedRoomLaunchPolicy(current.room_policy).anonymous);
622
- if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid === current.owner_seat_cid && seat.seat_state === 'active'))
622
+ if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid.toLowerCase() === current.owner_seat_cid.toLowerCase() && seat.seat_state === 'active'))
623
623
  throw new Error('expected Owner seat is not active');
624
624
  const seats = [];
625
625
  for (const seat of current.member_seats) {
@@ -1,3 +1,4 @@
1
+ import { WorkspaceDeviceStore } from './workspace-devices.js';
1
2
  import type { FastifyRequest } from 'fastify';
2
3
  import type { WebSocket } from 'ws';
3
4
  import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
@@ -26,6 +27,8 @@ export declare class WebAuth {
26
27
  private readonly now;
27
28
  private readonly devices;
28
29
  private readonly access;
30
+ private readonly workspaceDevices?;
31
+ readonly appOrigin: string;
29
32
  private _bootstrapSecret;
30
33
  private bootstrapExpiresAt;
31
34
  private bootstrapUsed;
@@ -35,7 +38,7 @@ export declare class WebAuth {
35
38
  private readonly rates;
36
39
  private readonly transports;
37
40
  private readonly sockets;
38
- constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
41
+ constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
39
42
  get bootstrapSecret(): string;
40
43
  get origin(): string;
41
44
  get host(): string;
@@ -66,6 +69,26 @@ export declare class WebAuth {
66
69
  bindTransport(sessionId: string, close: () => void): () => void;
67
70
  clearSessions(): void;
68
71
  revokeAllTrustedDevices(): number;
72
+ workspaceIdentity(): {
73
+ workspaceId: string;
74
+ };
75
+ workspaceEnrollment(issuer?: string | null): {
76
+ enrollment: string;
77
+ workspaceId: string;
78
+ expiresAt: number;
79
+ };
80
+ enrollWorkspace(request: FastifyRequest, input: {
81
+ enrollment: string;
82
+ workspaceId: string;
83
+ label: string;
84
+ }): {
85
+ token: string;
86
+ device: import("./workspace-devices.js").WorkspaceDevice;
87
+ workspaceId: string;
88
+ };
89
+ listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
90
+ revokeWorkspaceDevice(id: string): void;
91
+ private requireWorkspaceDevices;
69
92
  shutdown(): void;
70
93
  private createSession;
71
94
  private removeSession;
package/dist/web/auth.js CHANGED
@@ -14,6 +14,8 @@ export class WebAuth {
14
14
  now;
15
15
  devices;
16
16
  access;
17
+ workspaceDevices;
18
+ appOrigin;
17
19
  _bootstrapSecret = token(32);
18
20
  bootstrapExpiresAt = Date.now() + 5 * 60_000;
19
21
  bootstrapUsed = false;
@@ -23,12 +25,14 @@ export class WebAuth {
23
25
  rates = new Map();
24
26
  transports = new Map();
25
27
  sockets = new Map();
26
- constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
28
+ constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
27
29
  this._origin = _origin;
28
30
  this._host = _host;
29
31
  this.now = now;
30
32
  this.devices = devices;
31
33
  this.access = access;
34
+ this.workspaceDevices = workspaceDevices;
35
+ this.appOrigin = appOrigin;
32
36
  }
33
37
  get bootstrapSecret() { return this._bootstrapSecret; }
34
38
  get origin() { return this._origin; }
@@ -56,12 +60,17 @@ export class WebAuth {
56
60
  const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
57
61
  if (!host || !hosts.has(host))
58
62
  throw new FleetError('forbidden', `This address is not configured for the fleet console. Open ${this.origin} or set --public-origin.`);
59
- if (requireOrigin && (!request.headers.origin || !origins.has(request.headers.origin)))
63
+ if (this.workspaceDevices && String(request.headers.authorization ?? '').startsWith('Bearer ') && request.headers.origin && request.headers.origin !== this.appOrigin && !origins.has(request.headers.origin))
64
+ throw new FleetError('forbidden', 'workspace request Origin does not match configured account origin');
65
+ const workspaceRequest = Boolean(this.workspaceDevices) && request.headers.origin === this.appOrigin
66
+ && (String(request.headers.authorization ?? '').startsWith('Bearer ') || (request.url ?? '').split('?')[0] === '/api/v1/devices/enroll' || request.method === 'OPTIONS');
67
+ if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
60
68
  throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
61
69
  const fetchSite = request.headers['sec-fetch-site'];
62
70
  const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
63
- && request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document';
64
- if (!pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
71
+ && request.headers['sec-fetch-mode'] === 'navigate'
72
+ && (request.headers['sec-fetch-dest'] === 'document' || (Boolean(this.workspaceDevices) && request.headers['sec-fetch-dest'] === 'iframe' && new URL(request.url || '/', this.origin).searchParams.get('workspace-frame') === '1'));
73
+ if (!workspaceRequest && !pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
65
74
  throw new FleetError('forbidden', 'cross-site request rejected');
66
75
  }
67
76
  exchange(request) {
@@ -103,7 +112,23 @@ export class WebAuth {
103
112
  return { session: this.createSession(device.id), device };
104
113
  }
105
114
  authenticate(request, mutation = false) {
115
+ const authorization = String(request.headers.authorization ?? '');
116
+ if (authorization.startsWith('Bearer ')) {
117
+ this.validateBoundary(request, mutation);
118
+ if (!this.workspaceDevices)
119
+ throw new FleetError('unauthorized', 'workspace bearer credential is invalid');
120
+ const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
121
+ const id = `workspace:${device.id}`;
122
+ const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.expiresAt };
123
+ this.sessions.set(id, session);
124
+ this.sessionDevices.set(id, id);
125
+ if (mutation)
126
+ this.consumeRate(`mutation:${id}`, 120, 60000);
127
+ return session;
128
+ }
106
129
  this.validateBoundary(request, mutation);
130
+ if (request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
131
+ throw new FleetError('unauthorized', 'workspace bearer credential required');
107
132
  const id = parseCookies(request.headers.cookie ?? '').ofs_session;
108
133
  const session = id ? this.sessions.get(id) : undefined;
109
134
  const now = this.now();
@@ -124,7 +149,9 @@ export class WebAuth {
124
149
  logout(request) {
125
150
  const session = this.authenticate(request, true);
126
151
  const deviceId = this.sessionDevices.get(session.id);
127
- if (deviceId && deviceId !== 'unprotected')
152
+ if (session.id.startsWith('workspace:'))
153
+ this.workspaceDevices?.revoke(session.id.slice(10));
154
+ else if (deviceId && deviceId !== 'unprotected')
128
155
  this.devices.revokeId(deviceId);
129
156
  this.removeSession(session.id);
130
157
  }
@@ -146,7 +173,7 @@ export class WebAuth {
146
173
  || ticket.roleId !== roleId)
147
174
  throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
148
175
  const session = this.sessions.get(ticket.sessionId);
149
- if (!session)
176
+ if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
150
177
  throw new FleetError('unauthorized', 'browser session expired');
151
178
  return session;
152
179
  }
@@ -157,7 +184,10 @@ export class WebAuth {
157
184
  this.sockets.set(sessionId, sockets);
158
185
  }
159
186
  sockets.add(socket);
160
- socket.once('close', () => sockets?.delete(socket));
187
+ const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
188
+ this.removeSession(sessionId); }, 1000);
189
+ expiry.unref();
190
+ socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
161
191
  }
162
192
  /** Raw service streams share the same logout/revocation boundary as native sockets. */
163
193
  bindTransport(sessionId, close) {
@@ -167,7 +197,10 @@ export class WebAuth {
167
197
  this.transports.set(sessionId, transports);
168
198
  }
169
199
  transports.add(close);
170
- return () => { transports.delete(close); if (!transports.size)
200
+ const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
201
+ this.removeSession(sessionId); }, 1000);
202
+ expiry.unref();
203
+ return () => { clearInterval(expiry); transports.delete(close); if (!transports.size)
171
204
  this.transports.delete(sessionId); };
172
205
  }
173
206
  clearSessions() {
@@ -185,10 +218,29 @@ export class WebAuth {
185
218
  }
186
219
  revokeAllTrustedDevices() {
187
220
  const count = this.devices.revokeAll();
221
+ for (const device of this.workspaceDevices?.list() ?? [])
222
+ this.workspaceDevices?.revoke(device.id);
188
223
  this.clearSessions();
189
224
  return count;
190
225
  }
191
- shutdown() { this.clearSessions(); }
226
+ workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
227
+ workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
228
+ enrollWorkspace(request, input) {
229
+ this.validateBoundary(request, true);
230
+ this.consumeRate('workspace-enrollment', 30, 60000);
231
+ return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
232
+ }
233
+ listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
234
+ revokeWorkspaceDevice(id) {
235
+ this.requireWorkspaceDevices().revoke(id);
236
+ this.removeSession(`workspace:${id}`);
237
+ }
238
+ requireWorkspaceDevices() {
239
+ if (!this.workspaceDevices)
240
+ throw new FleetError('capability_unavailable', 'workspace device access is not configured');
241
+ return this.workspaceDevices;
242
+ }
243
+ shutdown() { this.clearSessions(); this.workspaceDevices?.close(); }
192
244
  createSession(deviceId) {
193
245
  const now = this.now();
194
246
  const session = {
@@ -1,4 +1,4 @@
1
- export type WebControlCommand = 'open' | 'revoke-all';
1
+ export type WebControlCommand = 'open' | 'revoke-all' | 'link-device';
2
2
  export declare const webControlPath: (dir?: string) => string;
3
3
  export interface WebControlServer {
4
4
  path: string;
@@ -7,8 +7,9 @@ export interface WebControlServer {
7
7
  export declare function startWebControlServer(options: {
8
8
  dir?: string;
9
9
  onOpen(): void | Promise<void>;
10
+ onLinkDevice?(): Record<string, unknown> | Promise<Record<string, unknown>>;
10
11
  onRevokeAll(): void | Promise<void>;
11
12
  now?: () => number;
12
13
  rateLimit?: number;
13
14
  }): Promise<WebControlServer>;
14
- export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<void>;
15
+ export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<Record<string, unknown> | void>;
@@ -35,13 +35,16 @@ export async function startWebControlServer(options) {
35
35
  if (used > rateLimit)
36
36
  throw new FleetError('rate_limited', 'local web control rate limit exceeded');
37
37
  const parsed = JSON.parse(body.slice(0, body.indexOf('\n')));
38
- if (parsed.command === 'open')
38
+ let result;
39
+ if (parsed.command === 'link-device' && options.onLinkDevice)
40
+ result = await options.onLinkDevice();
41
+ else if (parsed.command === 'open')
39
42
  await options.onOpen();
40
43
  else if (parsed.command === 'revoke-all')
41
44
  await options.onRevokeAll();
42
45
  else
43
46
  throw new FleetError('invalid_request', 'unknown local web control command');
44
- socket.end(JSON.stringify({ ok: true }) + '\n');
47
+ socket.end(JSON.stringify({ ok: true, result }) + '\n');
45
48
  }
46
49
  catch (error) {
47
50
  const code = error instanceof FleetError ? error.code : 'internal';
@@ -66,7 +69,7 @@ export async function startWebControlServer(options) {
66
69
  };
67
70
  }
68
71
  export async function requestWebControl(command, path = webControlPath(), timeoutMs = 5_000) {
69
- await new Promise((resolve, reject) => {
72
+ return await new Promise((resolve, reject) => {
70
73
  const socket = createConnection(path);
71
74
  let response = '';
72
75
  let settled = false;
@@ -74,7 +77,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
74
77
  socket.destroy();
75
78
  reject(new FleetError('timeout', 'local web control request timed out'));
76
79
  }, timeoutMs);
77
- const finish = (error) => {
80
+ const finish = (error, result) => {
78
81
  if (settled)
79
82
  return;
80
83
  settled = true;
@@ -82,7 +85,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
82
85
  if (error)
83
86
  reject(error);
84
87
  else
85
- resolve();
88
+ resolve(result);
86
89
  };
87
90
  socket.once('connect', () => socket.write(JSON.stringify({ command }) + '\n'));
88
91
  socket.on('data', chunk => { response += chunk.toString('utf8'); });
@@ -92,7 +95,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
92
95
  const parsed = JSON.parse(response);
93
96
  if (!parsed.ok)
94
97
  throw new FleetError(parsed.error?.code === 'rate_limited' ? 'rate_limited' : 'rejected', parsed.error?.message ?? 'local web control request was rejected');
95
- finish();
98
+ finish(undefined, parsed.result);
96
99
  }
97
100
  catch (error) {
98
101
  finish(error);
@@ -4,6 +4,7 @@ import type { WebAuth } from './auth.js';
4
4
  export interface ServiceTarget {
5
5
  prefix: string;
6
6
  origin: string;
7
+ upstreamPrefix?: string;
7
8
  /** Server-side service credentials; never taken from browser headers. */
8
9
  headers?: Record<string, string>;
9
10
  /** Dedicated credential-authenticated services reject direct browser context. */
@@ -12,6 +12,8 @@ export function createPrefixGateway(options) {
12
12
  if (!/^\/[a-z][a-z0-9-]*$/.test(target.prefix) || target.prefix === '/fleet')
13
13
  throw new Error('invalid service prefix');
14
14
  const origin = new URL(target.origin);
15
+ if (target.upstreamPrefix && !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]*$/.test(target.upstreamPrefix))
16
+ throw new Error('invalid upstream service prefix');
15
17
  if (!['http:', 'https:'].includes(origin.protocol) || origin.pathname !== '/' || origin.search || origin.hash || origin.username || origin.password)
16
18
  throw new Error('service target must be an HTTP origin');
17
19
  return { ...target, origin: origin.origin };
@@ -26,11 +28,11 @@ export function createPrefixGateway(options) {
26
28
  const service = services.find(x => path === x.prefix || path.startsWith(x.prefix + '/'));
27
29
  const fleet = path === '/fleet/api' || path.startsWith('/fleet/api/');
28
30
  return { service, page: !service && !fleet && !path.startsWith('/api/'), origin: service?.origin ?? options.fleetOrigin,
29
- path: service ? (raw.slice(service.prefix.length) || '/').replace(/^\?/, '/?') : fleet ? raw.slice('/fleet'.length) : raw };
31
+ path: service ? (service.upstreamPrefix || '') + (raw.slice(service.prefix.length) || '/').replace(/^\?/, '/?') : fleet ? raw.slice('/fleet'.length) : raw };
30
32
  };
31
33
  const prepare = (req, upgrade = false) => {
32
34
  const route = select(req);
33
- const request = { headers: req.headers, method: req.method };
35
+ const request = { headers: req.headers, method: req.method, url: route.path };
34
36
  if (!auth && (req.headers.origin || req.headers['sec-fetch-site'] || !route.service))
35
37
  throw new Error('machine-client listener rejects browser requests');
36
38
  auth?.validateBoundary(request, upgrade, route.page);
@@ -44,11 +46,11 @@ export function createPrefixGateway(options) {
44
46
  if (auth)
45
47
  for (const name of ['cookie', 'authorization', 'x-csrf-token', 'x-ours-api-token'])
46
48
  delete headers[name];
47
- Object.assign(headers, route.service.headers);
48
49
  if (route.service.stripBrowserContext) {
49
50
  for (const name of ['origin', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'])
50
51
  delete headers[name];
51
52
  }
53
+ Object.assign(headers, route.service.headers);
52
54
  headers.host = new URL(route.origin).host;
53
55
  }
54
56
  if (upgrade) {
@@ -1,10 +1,15 @@
1
+ import { validateAccountOrigin } from '../account-origin.js';
2
+ import { startWorkspaceTunnel } from './workspace-tunnel.js';
3
+ import { createPrefixGateway } from './prefix-gateway.js';
4
+ import { readClientProfile } from '../client-profile.js';
5
+ import { readPrivateFile } from '@ours.network/sdk/connector';
1
6
  import { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
2
7
  import { RoomLayoutService } from '../rooms-tasks/layout-service.js';
3
8
  import { spawn } from 'node:child_process';
4
9
  import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
5
10
  import { producerConfig } from '../notifications/outbox.js';
6
11
  import { SubscriptionService } from '../subscriptions/service.js';
7
- import { existsSync, realpathSync } from 'node:fs';
12
+ import { existsSync, realpathSync, readFileSync } from 'node:fs';
8
13
  import { resolve } from 'node:path';
9
14
  import { loadConfig } from '../config.js';
10
15
  import { RoleRepository } from '../application/role-repository.js';
@@ -30,6 +35,7 @@ import { RoomLayoutDefinitions } from '../application/room-layout-definitions.js
30
35
  import { PresetProvenance } from '../application/preset-provenance.js';
31
36
  import { doctor } from '../doctor.js';
32
37
  import { acquireWebServerLock } from './lock.js';
38
+ import { WorkspaceDeviceStore } from './workspace-devices.js';
33
39
  import { TrustedDeviceStore } from './device-store.js';
34
40
  import { WebAuth } from './auth.js';
35
41
  import { startWebControlServer } from './control.js';
@@ -62,12 +68,23 @@ export async function startWebConsole(options) {
62
68
  const lock = acquireWebServerLock(webDir);
63
69
  const bind = options.bind ?? '127.0.0.1';
64
70
  const publicOrigin = options.publicOrigin ? validatePublicOrigin(options.publicOrigin) : undefined;
71
+ const workspaceGateway = Boolean(publicOrigin && existsSync(resolve(stateRoot(), 'workspace', 'binding.json')));
65
72
  if (!isLoopback(bind) && !publicOrigin) {
66
73
  lock.release();
67
74
  throw new FleetError('forbidden', 'a non-loopback bind requires an explicit --public-origin');
68
75
  }
69
76
  const access = options.access ?? new WebAccessStore(webDir).read();
70
- const auth = new WebAuth(publicOrigin?.origin ?? `http://127.0.0.1:${requestedPort}`, publicOrigin?.host ?? `127.0.0.1:${requestedPort}`, Date.now, new TrustedDeviceStore(webDir), access);
77
+ const bindingFile = resolve(stateRoot(), 'workspace', 'binding.json');
78
+ let appOrigin = 'https://app.ours.network';
79
+ try {
80
+ if (existsSync(bindingFile))
81
+ appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin ?? appOrigin);
82
+ }
83
+ catch (error) {
84
+ lock.release();
85
+ throw error;
86
+ }
87
+ const auth = new WebAuth(publicOrigin?.origin ?? `http://127.0.0.1:${requestedPort}`, publicOrigin?.host ?? `127.0.0.1:${requestedPort}`, Date.now, new TrustedDeviceStore(webDir), access, new WorkspaceDeviceStore(webDir), appOrigin);
71
88
  const backend = pickBackend();
72
89
  const repository = new RoleRepository({
73
90
  configPath: options.configPath,
@@ -199,7 +216,7 @@ export async function startWebConsole(options) {
199
216
  }
200
217
  let address;
201
218
  try {
202
- address = await server.app.listen({ host: bind, port: requestedPort });
219
+ address = await server.app.listen({ host: workspaceGateway ? '127.0.0.1' : bind, port: workspaceGateway ? 0 : requestedPort });
203
220
  }
204
221
  catch (error) {
205
222
  await server.close();
@@ -210,11 +227,26 @@ export async function startWebConsole(options) {
210
227
  const localHost = `127.0.0.1:${actual.port}`;
211
228
  const browserOrigin = publicOrigin?.origin ?? `http://${localHost}`;
212
229
  const browserHost = publicOrigin?.host ?? localHost;
230
+ let gateway;
231
+ if (workspaceGateway)
232
+ try {
233
+ const profile = readClientProfile(), provider = new URL(profile.serverUrl), credential = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
234
+ const basePath = provider.pathname.replace(/\/$/, '');
235
+ const services = ['daemon', 'cowork', 'messenger'].map(name => ({ prefix: '/' + name, origin: provider.origin, upstreamPrefix: basePath + '/' + name, stripBrowserContext: true, headers: { 'X-Ours-Api-Token': credential, ...(name === 'messenger' ? { Origin: provider.origin, 'X-Ours-Messenger-CSRF': '1' } : name === 'cowork' ? { Origin: provider.origin } : {}) } }));
236
+ gateway = createPrefixGateway({ auth: server.auth, fleetOrigin: address, services });
237
+ await new Promise((resolve, reject) => { gateway.server.once('error', reject); gateway.server.listen(requestedPort, bind, () => { gateway.server.off('error', reject); resolve(); }); });
238
+ }
239
+ catch (error) {
240
+ await gateway?.close().catch(() => { });
241
+ await server.close();
242
+ lock.release();
243
+ throw error;
244
+ }
213
245
  server.auth.setBoundary(browserOrigin, browserHost, publicOrigin ? {
214
246
  // nginx's safe default uses the loopback upstream as Host. The declared
215
247
  // browser Origin remains mandatory for auth/mutations and WebSocket hello,
216
248
  // so operators do not need a fragile Host-rewrite incantation.
217
- hosts: [localHost, `localhost:${actual.port}`],
249
+ hosts: [localHost, `localhost:${actual.port}`, ...(workspaceGateway ? [`127.0.0.1:${requestedPort}`, `localhost:${requestedPort}`] : [])],
218
250
  } : {
219
251
  hosts: [`localhost:${actual.port}`], origins: [`http://localhost:${actual.port}`],
220
252
  });
@@ -228,10 +260,12 @@ export async function startWebConsole(options) {
228
260
  ? `${browserOrigin}/#bootstrap=${server.auth.mintBootstrap()}` : `${browserOrigin}/`;
229
261
  openBrowser(url);
230
262
  },
263
+ onLinkDevice() { return { version: 1, origin: browserOrigin, ...server.auth.workspaceEnrollment() }; },
231
264
  onRevokeAll() { server.auth.revokeAllTrustedDevices(); },
232
265
  });
233
266
  }
234
267
  catch (error) {
268
+ await gateway?.close();
235
269
  await server.close();
236
270
  lock.release();
237
271
  throw error;
@@ -239,13 +273,30 @@ export async function startWebConsole(options) {
239
273
  if (options.open !== false)
240
274
  openBrowser(access.mode === 'pairing'
241
275
  ? `${browserOrigin}/#bootstrap=${server.auth.bootstrapSecret}` : `${browserOrigin}/`);
276
+ let stopTunnel;
277
+ try {
278
+ stopTunnel = startWorkspaceTunnel(browserOrigin);
279
+ }
280
+ catch (error) {
281
+ try {
282
+ await control?.close();
283
+ await gateway?.close();
284
+ await server.close();
285
+ }
286
+ finally {
287
+ lock.release();
288
+ }
289
+ throw error;
290
+ }
242
291
  const notifications = notificationConfig ? new FleetNotificationProducer([agentsRoot(), tmpRoot()], resolve(webDir, 'notification-outboxes'), notificationConfig, options.log) : undefined;
243
292
  return {
244
293
  ...server, address: browserOrigin,
245
294
  async close() {
246
295
  try {
296
+ await stopTunnel();
247
297
  await notifications?.close();
248
298
  await control?.close();
299
+ await gateway?.close();
249
300
  await server.close();
250
301
  }
251
302
  finally {
@@ -1,3 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { packagedPresetRoot } from '../preset-bootstrap.js';
3
+ import { validateCatalog } from '../init-wizard.js';
4
+ import { getAdapter } from '../harness/registry.js';
1
5
  import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentAttachment, prepareAttachmentPresentation, presentAttachmentEvent } from './agent-attachments.js';
2
6
  import { readChatIdle } from '../temp-idle.js';
3
7
  import { agentDir } from '../paths.js';
@@ -39,12 +43,21 @@ export async function buildWebServer(services, boundary, options = {}) {
39
43
  reply.header('X-Content-Type-Options', 'nosniff');
40
44
  reply.header('Referrer-Policy', 'no-referrer');
41
45
  reply.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()');
42
- reply.header('X-Frame-Options', 'DENY');
46
+ // Only the configured account launcher may embed workspace pages.
43
47
  reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; ` +
44
48
  `connect-src 'self' ${auth.secureCookies ? 'wss' : 'ws'}://${auth.host}; object-src 'none'; base-uri 'none'; ` +
45
- `frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
49
+ `frame-ancestors 'self' ${auth.appOrigin}; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
46
50
  if (request.url.startsWith('/api/'))
47
51
  reply.header('Cache-Control', 'no-store');
52
+ if (request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
53
+ reply.header('Access-Control-Allow-Origin', auth.appOrigin).header('Vary', 'Origin');
54
+ reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
55
+ reply.header('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-CSRF-Token,Idempotency-Key');
56
+ if (request.method === 'OPTIONS') {
57
+ auth.validateBoundary(request, false);
58
+ return reply.code(204).send();
59
+ }
60
+ }
48
61
  try {
49
62
  auth.validateBoundary(request, false, !request.url.startsWith('/api/'));
50
63
  }
@@ -117,6 +130,20 @@ export async function buildWebServer(services, boundary, options = {}) {
117
130
  const session = auth.authenticate(request);
118
131
  return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
119
132
  });
133
+ app.get('/api/v1/workspace/identity', async () => auth.workspaceIdentity());
134
+ app.post('/api/v1/devices/enroll', async (request, reply) => {
135
+ reply.header('Cache-Control', 'no-store');
136
+ const input = request.body;
137
+ if (!input)
138
+ throw new FleetError('invalid_request', 'device enrollment body required');
139
+ return auth.enrollWorkspace(request, input);
140
+ });
141
+ app.post('/api/v1/devices/link', async (request) => {
142
+ const session = auth.authenticate(request, true);
143
+ return { version: 1, origin: auth.origin, ...auth.workspaceEnrollment(session.id) };
144
+ });
145
+ app.get('/api/v1/devices', async (request) => { auth.authenticate(request); return { devices: auth.listWorkspaceDevices() }; });
146
+ app.delete('/api/v1/devices/:id', async (request) => { auth.authenticate(request, true); auth.revokeWorkspaceDevice(request.params.id); return { revoked: true }; });
120
147
  app.get('/api/v1/meta', async (request) => {
121
148
  auth.authenticate(request);
122
149
  return {
@@ -127,6 +154,19 @@ export async function buildWebServer(services, boundary, options = {}) {
127
154
  auditDegraded: audit.degraded,
128
155
  };
129
156
  });
157
+ app.get('/api/v1/onboarding', async (request) => {
158
+ auth.authenticate(request);
159
+ const harnesses = await Promise.all(['codex', 'claude-code'].map(async (harness) => {
160
+ try {
161
+ return { harness, ...await getAdapter(harness).checkPrereqs() };
162
+ }
163
+ catch {
164
+ return { harness, ok: false, checks: [{ name: 'availability', ok: false, detail: 'Harness checks unavailable' }] };
165
+ }
166
+ }));
167
+ const catalog = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8')));
168
+ return { harnesses, catalog: catalog.models, providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator' };
169
+ });
130
170
  app.get('/api/v1/creation-capabilities', async (request) => {
131
171
  auth.authenticate(request);
132
172
  return services.creation.capabilities();
@@ -0,0 +1,29 @@
1
+ export interface WorkspaceDevice {
2
+ id: string;
3
+ label: string;
4
+ createdAt: number;
5
+ lastUsedAt: number;
6
+ expiresAt: number;
7
+ revokedAt: number | null;
8
+ }
9
+ export declare class WorkspaceDeviceStore {
10
+ private readonly now;
11
+ private readonly db;
12
+ readonly workspaceId: string;
13
+ constructor(dir?: string, now?: () => number);
14
+ mint(issuer?: string | null): {
15
+ enrollment: string;
16
+ workspaceId: string;
17
+ expiresAt: number;
18
+ };
19
+ enroll(enrollment: string, workspaceId: string, label: string): {
20
+ token: string;
21
+ device: WorkspaceDevice;
22
+ workspaceId: string;
23
+ };
24
+ authenticate(token: string): WorkspaceDevice;
25
+ valid(id: string): boolean;
26
+ list(): WorkspaceDevice[];
27
+ revoke(id: string): void;
28
+ close(): void;
29
+ }
@@ -0,0 +1,84 @@
1
+ import { createRequire } from 'node:module';
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { mkdirSync, chmodSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { stateRoot } from '../paths.js';
6
+ import { FleetError } from '../application/errors.js';
7
+ const opaque = () => randomBytes(32).toString('base64url');
8
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
9
+ export class WorkspaceDeviceStore {
10
+ now;
11
+ db;
12
+ workspaceId;
13
+ constructor(dir = join(stateRoot(), 'web'), now = Date.now) {
14
+ this.now = now;
15
+ const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
16
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ chmodSync(dir, 0o700);
18
+ const path = join(dir, 'workspace-devices.db');
19
+ this.db = new DatabaseSync(path);
20
+ chmodSync(path, 0o600);
21
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
22
+ CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY,hash TEXT NOT NULL,label TEXT NOT NULL,createdAt INTEGER NOT NULL,lastUsedAt INTEGER NOT NULL,expiresAt INTEGER NOT NULL,revokedAt INTEGER);
24
+ CREATE TABLE IF NOT EXISTS enrollments(hash TEXT PRIMARY KEY,expiresAt INTEGER NOT NULL,issuer TEXT);`);
25
+ this.db.prepare('INSERT OR IGNORE INTO metadata VALUES(?,?)').run('workspaceId', opaque());
26
+ this.workspaceId = String(this.db.prepare('SELECT value FROM metadata WHERE key=?').get('workspaceId').value);
27
+ }
28
+ mint(issuer = null) {
29
+ const now = this.now(), enrollment = opaque(), expiresAt = now + 5 * 60000;
30
+ this.db.exec('BEGIN IMMEDIATE');
31
+ try {
32
+ this.db.prepare('DELETE FROM enrollments WHERE expiresAt<=?').run(now);
33
+ const count = Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n);
34
+ if (count >= 32)
35
+ throw new FleetError('rate_limited', 'too many pending device links');
36
+ this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), expiresAt, issuer);
37
+ this.db.exec('COMMIT');
38
+ return { enrollment, workspaceId: this.workspaceId, expiresAt };
39
+ }
40
+ catch (error) {
41
+ this.db.exec('ROLLBACK');
42
+ throw error;
43
+ }
44
+ }
45
+ enroll(enrollment, workspaceId, label) {
46
+ if (workspaceId !== this.workspaceId || typeof enrollment !== 'string' || !/^[\w-]{43}$/.test(enrollment))
47
+ throw new FleetError('unauthorized', 'invalid device enrollment');
48
+ if (typeof label !== 'string' || !label.trim() || label.length > 100 || /[\x00-\x1f\x7f]/.test(label))
49
+ throw new FleetError('invalid_request', 'device name must contain 1–100 printable characters');
50
+ const now = this.now(), id = opaque(), token = `${id}.${opaque()}`;
51
+ this.db.exec('BEGIN IMMEDIATE');
52
+ try {
53
+ const code = this.db.prepare('DELETE FROM enrollments WHERE hash=? AND expiresAt>? RETURNING issuer').get(hash(enrollment), now);
54
+ if (!code)
55
+ throw new FleetError('unauthorized', 'device enrollment is expired or already used');
56
+ if (code.issuer && !this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(code.issuer, now))
57
+ throw new FleetError('unauthorized', 'issuing device has been revoked');
58
+ const count = Number(this.db.prepare('SELECT count(*) AS n FROM devices WHERE revokedAt IS NULL AND expiresAt>?').get(now).n);
59
+ if (count >= 128)
60
+ throw new FleetError('rate_limited', 'authorized device limit reached');
61
+ const device = { id, label: label.trim(), createdAt: now, lastUsedAt: now, expiresAt: now + 90 * 86400000, revokedAt: null };
62
+ this.db.prepare('INSERT INTO devices VALUES(?,?,?,?,?,?,NULL)').run(id, hash(token), device.label, now, now, device.expiresAt);
63
+ this.db.exec('COMMIT');
64
+ return { token, device, workspaceId };
65
+ }
66
+ catch (error) {
67
+ this.db.exec('ROLLBACK');
68
+ throw error;
69
+ }
70
+ }
71
+ authenticate(token) {
72
+ const id = typeof token === 'string' ? token.split('.')[0] : '', now = this.now();
73
+ const row = this.db.prepare('SELECT * FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, now);
74
+ const supplied = Buffer.from(hash(token || '')), expected = Buffer.from(String(row?.hash || '0'.repeat(64)));
75
+ if (!row || !timingSafeEqual(supplied, expected))
76
+ throw new FleetError('unauthorized', 'workspace device is missing, expired, or revoked');
77
+ this.db.prepare('UPDATE devices SET lastUsedAt=? WHERE id=?').run(now, id);
78
+ return { id, label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: now, expiresAt: Number(row.expiresAt), revokedAt: null };
79
+ }
80
+ valid(id) { return Boolean(this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, this.now())); }
81
+ list() { return this.db.prepare('SELECT id,label,createdAt,lastUsedAt,expiresAt,revokedAt FROM devices ORDER BY createdAt DESC LIMIT 256').all().map(row => ({ id: String(row.id), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: row.revokedAt === null ? null : Number(row.revokedAt) })); }
82
+ revoke(id) { this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(this.now(), id); this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(id); }
83
+ close() { this.db.close(); }
84
+ }
@@ -0,0 +1 @@
1
+ export declare function startWorkspaceTunnel(origin: string): () => Promise<void>;
@@ -0,0 +1,32 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { stateRoot } from '../paths.js';
5
+ export function startWorkspaceTunnel(origin) {
6
+ const dir = join(stateRoot(), 'workspace'), file = join(dir, 'tunnel.json');
7
+ if (!existsSync(file))
8
+ return async () => { };
9
+ const config = JSON.parse(readFileSync(file, 'utf8'));
10
+ if (config.origin !== origin)
11
+ throw Error('Workspace tunnel requires its configured public origin');
12
+ const tokenFile = join(dir, 'connector');
13
+ if (config.tokenFile !== tokenFile)
14
+ throw Error('Unexpected connector credential path');
15
+ const stat = lstatSync(tokenFile);
16
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())
17
+ throw Error('Connector credential must be an owned private file');
18
+ let stopped = false, child, timer, backoff = 1000;
19
+ const launch = () => {
20
+ if (stopped)
21
+ return;
22
+ child = spawn('cloudflared', ['tunnel', '--no-autoupdate', 'run', '--token-file', tokenFile], { stdio: 'ignore' });
23
+ let scheduled = false;
24
+ const retry = () => { if (scheduled || stopped)
25
+ return; scheduled = true; timer = setTimeout(launch, backoff); backoff = Math.min(30000, backoff * 2); };
26
+ child.once('error', retry);
27
+ child.once('exit', retry);
28
+ };
29
+ launch();
30
+ return async () => { stopped = true; clearTimeout(timer); const current = child; if (!current || current.exitCode !== null)
31
+ return; await new Promise(resolve => { const timeout = setTimeout(() => { current.kill('SIGKILL'); resolve(); }, 5000); current.once('exit', () => { clearTimeout(timeout); resolve(); }); current.kill('SIGTERM'); }); };
32
+ }
@@ -0,0 +1,24 @@
1
+ export interface WorkspacePayload {
2
+ version: 1;
3
+ appOrigin: string;
4
+ hostname: string;
5
+ rootName: string;
6
+ name: string;
7
+ surname: string;
8
+ connectorToken: string;
9
+ invitation: string;
10
+ serverCid: string;
11
+ challenge: {
12
+ nonce: string;
13
+ workspaceId: string;
14
+ accountId: string;
15
+ expiresAt: number;
16
+ };
17
+ }
18
+ export declare function readWorkspacePayload(file: string): WorkspacePayload;
19
+ export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
20
+ migrateAppOrigin?: boolean;
21
+ }): Promise<{
22
+ origin: string;
23
+ hostWorkspaceId: string;
24
+ }>;
@@ -0,0 +1,109 @@
1
+ import { validateAccountOrigin } from './account-origin.js';
2
+ import { lstatSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { readClientProfile } from './client-profile.js';
5
+ import { stateRoot, defaultConfigPath } from './paths.js';
6
+ import { replaceFileAtomically } from './atomic-file.js';
7
+ import { loadConfig, splitRootFor } from './config.js';
8
+ import { stringify } from 'yaml';
9
+ import { WebAccessStore } from './web/access.js';
10
+ import { WorkspaceDeviceStore } from './web/workspace-devices.js';
11
+ export function readWorkspacePayload(file) {
12
+ const path = resolve(file), stat = lstatSync(path);
13
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.size > 32768)
14
+ throw Error('Setup payload requires an owned private regular file (chmod 600)');
15
+ let p;
16
+ try {
17
+ p = JSON.parse(Buffer.from(readFileSync(path, 'utf8').trim(), 'base64url').toString());
18
+ }
19
+ catch {
20
+ throw Error('Invalid workspace payload');
21
+ }
22
+ if (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]))) || p.challenge.expiresAt <= Date.now() || p.challenge.expiresAt > Date.now() + 16 * 60000)
23
+ throw Error('Workspace payload is invalid or expired');
24
+ for (const k of ['connectorToken', 'invitation', 'name', 'surname'])
25
+ if (typeof p[k] !== 'string' || !p[k] || p[k].length > 8192 || /[\x00-\x1f\x7f]/.test(p[k]))
26
+ throw Error('Invalid workspace payload');
27
+ return p;
28
+ }
29
+ export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
30
+ const profile = readClientProfile();
31
+ const base = new URL(profile.endpoint);
32
+ base.pathname = base.pathname.replace(/\/daemon\/?$/, '/messenger/');
33
+ if (!base.pathname.endsWith('/messenger/'))
34
+ throw Error('Workspace setup requires the supported gateway client profile');
35
+ const secret = readFileSync(profile.credentialPath, 'utf8').trim();
36
+ const request = 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(15000), redirect: 'error' }); if (!response.ok) {
37
+ await response.body?.cancel();
38
+ throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
39
+ } return response.json(); };
40
+ if (new WebAccessStore().read().mode === 'none')
41
+ throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
42
+ const dir = join(stateRoot(), 'workspace');
43
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ const appOrigin = validateAccountOrigin(payload.appOrigin);
45
+ const previousFile = join(dir, 'binding.json');
46
+ let migrating = false;
47
+ if (existsSync(previousFile)) {
48
+ const previous = JSON.parse(readFileSync(previousFile, 'utf8'));
49
+ if (previous.workspaceId !== payload.challenge.workspaceId)
50
+ throw Error('This host is already associated with another workspace');
51
+ const priorOrigin = validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network');
52
+ migrating = priorOrigin !== appOrigin;
53
+ if (migrating && !options.migrateAppOrigin)
54
+ throw Error('Account origin change requires explicit --migrate-app-origin and a fresh setup payload');
55
+ if (migrating && previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase())
56
+ throw Error('Account origin migration must retain the enrollment server identity');
57
+ }
58
+ const loaded = loadConfig(configuration);
59
+ if (loaded.rooms?.defaults?.attach_owner === false)
60
+ throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
61
+ if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
62
+ throw Error('Existing room owner configuration must be reviewed before workspace enrollment');
63
+ const identity = await request('workspace/enrollment-identity');
64
+ if (!/^[a-f0-9]{64}$/i.test(identity.cid))
65
+ throw Error('Bound Messenger identity unavailable');
66
+ if (loaded.rooms && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
67
+ throw Error('Retained room owner CID conflicts with the Human root');
68
+ const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
69
+ if (existsSync(inviteFile)) {
70
+ const stat = lstatSync(inviteFile);
71
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0)
72
+ throw Error('Retained Owner invitation file must be owned and private');
73
+ }
74
+ const devices = new WorkspaceDeviceStore();
75
+ const hostWorkspaceId = devices.workspaceId;
76
+ devices.close();
77
+ const result = await request('workspace/enroll', { ...payload, hostWorkspaceId });
78
+ if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
79
+ throw Error('Malformed workspace enrollment response');
80
+ if (loaded.rooms && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
81
+ throw Error('Retained room owner CID conflicts with the Human root');
82
+ if (migrating) {
83
+ const deadline = Date.now() + 15000;
84
+ while (true) {
85
+ const response = await fetch(appOrigin + '/account-api/workspace-proof', { method: 'POST', headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(5000), body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid: result.rootCid }) });
86
+ if (!response.ok) {
87
+ await response.body?.cancel();
88
+ throw Error('Account origin migration proof was rejected; local trust unchanged');
89
+ }
90
+ const receipt = await response.json();
91
+ if (receipt.verified === true)
92
+ break;
93
+ if (Date.now() >= deadline)
94
+ throw Error('Account origin migration proof is not confirmed; local trust unchanged');
95
+ await new Promise(resolve => setTimeout(resolve, 500));
96
+ }
97
+ }
98
+ replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
99
+ const split = splitRootFor(configuration);
100
+ mkdirSync(split, { recursive: true, mode: 0o700 });
101
+ if (!loaded.rooms)
102
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify({ rooms: { owner: { provider: 'messenger-server', expected_cid: result.rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } }), 0o600);
103
+ replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
104
+ const origin = `https://${payload.hostname}`;
105
+ replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
106
+ replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase() }) + '\n', 0o600);
107
+ loadConfig(configuration);
108
+ return { origin, hostWorkspaceId };
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.25",
3
+ "version": "1.2.0-nightly.27",
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",
@@ -23,7 +23,7 @@
23
23
  "LICENSE"
24
24
  ],
25
25
  "engines": {
26
- "node": ">=22"
26
+ "node": ">=22.13.0"
27
27
  },
28
28
  "scripts": {
29
29
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
@@ -41,11 +41,12 @@
41
41
  "@agentclientprotocol/sdk": "^1.3.0",
42
42
  "@fastify/static": "^10.1.2",
43
43
  "@fastify/websocket": "^11.2.0",
44
- "@ours.network/cli": "2.8.1-nightly.12",
44
+ "@ours.network/cli": "2.8.1-nightly.13",
45
45
  "@ours.network/mcp": "1.2.0-nightly.9",
46
46
  "@ours.network/sdk": "3.8.1-nightly.15",
47
47
  "commander": "^12.1.0",
48
48
  "fastify": "^5.4.0",
49
+ "qrcode": "1.5.4",
49
50
  "secure-json-parse": "^4.1.0",
50
51
  "ws": "^8.18.3",
51
52
  "yaml": "^2.5.0"
@@ -56,7 +57,8 @@
56
57
  },
57
58
  "devDependencies": {
58
59
  "@ours.network/daemon": "3.8.1-nightly.7",
59
- "@types/node": "^20.14.0",
60
+ "@types/node": "^22.20.4",
61
+ "@types/qrcode": "^1.5.6",
60
62
  "@types/ws": "^8.18.1",
61
63
  "typescript": "^5.5.0",
62
64
  "vitest": "^2.0.0"