@ours.network/fleet 1.2.0-nightly.25 → 1.2.0-nightly.26
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 +2 -0
- package/dist/account-origin.js +6 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.js +43 -1
- package/dist/rooms-tasks/live-readiness.js +2 -2
- package/dist/rooms-tasks/provision.js +1 -1
- package/dist/web/auth.d.ts +24 -1
- package/dist/web/auth.js +61 -9
- package/dist/web/control.d.ts +3 -2
- package/dist/web/control.js +9 -6
- package/dist/web/prefix-gateway.d.ts +1 -0
- package/dist/web/prefix-gateway.js +5 -3
- package/dist/web/runtime.js +55 -4
- package/dist/web/server.js +42 -2
- package/dist/web/workspace-devices.d.ts +29 -0
- package/dist/web/workspace-devices.js +84 -0
- package/dist/web/workspace-tunnel.d.ts +1 -0
- package/dist/web/workspace-tunnel.js +32 -0
- package/dist/workspace-enrollment.d.ts +24 -0
- package/dist/workspace-enrollment.js +109 -0
- package/package.json +5 -3
|
@@ -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
|
+
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.26",
|
|
3
|
+
"buildId": "7b8a6a05d7bf",
|
|
4
|
+
"commit": "99c715eed295baa8fff6bff9f42f868d1f7246f5",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-
|
|
6
|
+
"builtAt": "2026-10-01T06:37:30.669Z",
|
|
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) {
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -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 (
|
|
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'
|
|
64
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
package/dist/web/control.d.ts
CHANGED
|
@@ -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>;
|
package/dist/web/control.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/web/runtime.js
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/web/server.js
CHANGED
|
@@ -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
|
-
|
|
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 '
|
|
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.
|
|
3
|
+
"version": "1.2.0-nightly.26",
|
|
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})\"",
|
|
@@ -46,6 +46,7 @@
|
|
|
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.
|
|
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"
|