@ours.network/fleet 1.2.0-nightly.24 → 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/README.md +5 -0
- 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/notifications/fleet-producer.d.ts +18 -0
- package/dist/notifications/fleet-producer.js +129 -0
- package/dist/notifications/outbox.d.ts +31 -0
- package/dist/notifications/outbox.js +104 -0
- 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 +3 -0
- package/dist/web/prefix-gateway.js +8 -2
- package/dist/web/runtime.js +61 -5
- package/dist/web/server.js +47 -3
- 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
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,8 +4,11 @@ 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>;
|
|
10
|
+
/** Dedicated credential-authenticated services reject direct browser context. */
|
|
11
|
+
stripBrowserContext?: boolean;
|
|
9
12
|
}
|
|
10
13
|
export interface GatewayOptions {
|
|
11
14
|
/** Backend mode is for a separate loopback machine-client listener; browsers are rejected. */
|
|
@@ -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,6 +46,10 @@ 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];
|
|
49
|
+
if (route.service.stripBrowserContext) {
|
|
50
|
+
for (const name of ['origin', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'])
|
|
51
|
+
delete headers[name];
|
|
52
|
+
}
|
|
47
53
|
Object.assign(headers, route.service.headers);
|
|
48
54
|
headers.host = new URL(route.origin).host;
|
|
49
55
|
}
|
package/dist/web/runtime.js
CHANGED
|
@@ -1,8 +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';
|
|
9
|
+
import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
|
|
10
|
+
import { producerConfig } from '../notifications/outbox.js';
|
|
4
11
|
import { SubscriptionService } from '../subscriptions/service.js';
|
|
5
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { existsSync, realpathSync, readFileSync } from 'node:fs';
|
|
6
13
|
import { resolve } from 'node:path';
|
|
7
14
|
import { loadConfig } from '../config.js';
|
|
8
15
|
import { RoleRepository } from '../application/role-repository.js';
|
|
@@ -16,7 +23,7 @@ import { FleetError } from '../application/errors.js';
|
|
|
16
23
|
import { controlRequest, controlSocketPath } from '../session/control.js';
|
|
17
24
|
import { pickBackend } from '../supervisor/index.js';
|
|
18
25
|
import { realExec } from '../exec.js';
|
|
19
|
-
import { home, stateRoot } from '../paths.js';
|
|
26
|
+
import { home, stateRoot, agentsRoot, tmpRoot } from '../paths.js';
|
|
20
27
|
import { AuditSink } from './audit.js';
|
|
21
28
|
import { FleetEventBus } from './events.js';
|
|
22
29
|
import { buildWebServer } from './server.js';
|
|
@@ -28,6 +35,7 @@ import { RoomLayoutDefinitions } from '../application/room-layout-definitions.js
|
|
|
28
35
|
import { PresetProvenance } from '../application/preset-provenance.js';
|
|
29
36
|
import { doctor } from '../doctor.js';
|
|
30
37
|
import { acquireWebServerLock } from './lock.js';
|
|
38
|
+
import { WorkspaceDeviceStore } from './workspace-devices.js';
|
|
31
39
|
import { TrustedDeviceStore } from './device-store.js';
|
|
32
40
|
import { WebAuth } from './auth.js';
|
|
33
41
|
import { startWebControlServer } from './control.js';
|
|
@@ -52,6 +60,7 @@ function cachedConfigProvider(configPath) {
|
|
|
52
60
|
};
|
|
53
61
|
}
|
|
54
62
|
export async function startWebConsole(options) {
|
|
63
|
+
const notificationConfig = producerConfig();
|
|
55
64
|
const requestedPort = options.port ?? 49_271;
|
|
56
65
|
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535)
|
|
57
66
|
throw new FleetError('invalid_request', 'port must be between 0 and 65535');
|
|
@@ -59,12 +68,23 @@ export async function startWebConsole(options) {
|
|
|
59
68
|
const lock = acquireWebServerLock(webDir);
|
|
60
69
|
const bind = options.bind ?? '127.0.0.1';
|
|
61
70
|
const publicOrigin = options.publicOrigin ? validatePublicOrigin(options.publicOrigin) : undefined;
|
|
71
|
+
const workspaceGateway = Boolean(publicOrigin && existsSync(resolve(stateRoot(), 'workspace', 'binding.json')));
|
|
62
72
|
if (!isLoopback(bind) && !publicOrigin) {
|
|
63
73
|
lock.release();
|
|
64
74
|
throw new FleetError('forbidden', 'a non-loopback bind requires an explicit --public-origin');
|
|
65
75
|
}
|
|
66
76
|
const access = options.access ?? new WebAccessStore(webDir).read();
|
|
67
|
-
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);
|
|
68
88
|
const backend = pickBackend();
|
|
69
89
|
const repository = new RoleRepository({
|
|
70
90
|
configPath: options.configPath,
|
|
@@ -196,7 +216,7 @@ export async function startWebConsole(options) {
|
|
|
196
216
|
}
|
|
197
217
|
let address;
|
|
198
218
|
try {
|
|
199
|
-
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 });
|
|
200
220
|
}
|
|
201
221
|
catch (error) {
|
|
202
222
|
await server.close();
|
|
@@ -207,11 +227,26 @@ export async function startWebConsole(options) {
|
|
|
207
227
|
const localHost = `127.0.0.1:${actual.port}`;
|
|
208
228
|
const browserOrigin = publicOrigin?.origin ?? `http://${localHost}`;
|
|
209
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
|
+
}
|
|
210
245
|
server.auth.setBoundary(browserOrigin, browserHost, publicOrigin ? {
|
|
211
246
|
// nginx's safe default uses the loopback upstream as Host. The declared
|
|
212
247
|
// browser Origin remains mandatory for auth/mutations and WebSocket hello,
|
|
213
248
|
// so operators do not need a fragile Host-rewrite incantation.
|
|
214
|
-
hosts: [localHost, `localhost:${actual.port}`],
|
|
249
|
+
hosts: [localHost, `localhost:${actual.port}`, ...(workspaceGateway ? [`127.0.0.1:${requestedPort}`, `localhost:${requestedPort}`] : [])],
|
|
215
250
|
} : {
|
|
216
251
|
hosts: [`localhost:${actual.port}`], origins: [`http://localhost:${actual.port}`],
|
|
217
252
|
});
|
|
@@ -225,10 +260,12 @@ export async function startWebConsole(options) {
|
|
|
225
260
|
? `${browserOrigin}/#bootstrap=${server.auth.mintBootstrap()}` : `${browserOrigin}/`;
|
|
226
261
|
openBrowser(url);
|
|
227
262
|
},
|
|
263
|
+
onLinkDevice() { return { version: 1, origin: browserOrigin, ...server.auth.workspaceEnrollment() }; },
|
|
228
264
|
onRevokeAll() { server.auth.revokeAllTrustedDevices(); },
|
|
229
265
|
});
|
|
230
266
|
}
|
|
231
267
|
catch (error) {
|
|
268
|
+
await gateway?.close();
|
|
232
269
|
await server.close();
|
|
233
270
|
lock.release();
|
|
234
271
|
throw error;
|
|
@@ -236,11 +273,30 @@ export async function startWebConsole(options) {
|
|
|
236
273
|
if (options.open !== false)
|
|
237
274
|
openBrowser(access.mode === 'pairing'
|
|
238
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
|
+
}
|
|
291
|
+
const notifications = notificationConfig ? new FleetNotificationProducer([agentsRoot(), tmpRoot()], resolve(webDir, 'notification-outboxes'), notificationConfig, options.log) : undefined;
|
|
239
292
|
return {
|
|
240
293
|
...server, address: browserOrigin,
|
|
241
294
|
async close() {
|
|
242
295
|
try {
|
|
296
|
+
await stopTunnel();
|
|
297
|
+
await notifications?.close();
|
|
243
298
|
await control?.close();
|
|
299
|
+
await gateway?.close();
|
|
244
300
|
await server.close();
|
|
245
301
|
}
|
|
246
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();
|
|
@@ -937,7 +977,11 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
937
977
|
app.setNotFoundHandler((request, reply) => {
|
|
938
978
|
if (request.url.startsWith('/api/'))
|
|
939
979
|
return reply.code(404).send({ error: 'not found' });
|
|
940
|
-
|
|
980
|
+
const path = request.url.split('?')[0];
|
|
981
|
+
if ((path === '/chats' || path.startsWith('/chats/')) && existsSync(join(staticRoot, 'fleet-index.html')))
|
|
982
|
+
return reply.redirect((request.url.includes('?') ? '/fleet/chats' : '/fleet') + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''));
|
|
983
|
+
const fleetEntry = (path === '/fleet' || path.startsWith('/fleet/')) && existsSync(join(staticRoot, 'fleet-index.html'));
|
|
984
|
+
return reply.sendFile(fleetEntry ? 'fleet-index.html' : 'index.html');
|
|
941
985
|
});
|
|
942
986
|
}
|
|
943
987
|
return {
|
|
@@ -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
|
+
}
|