@ours.network/fleet 1.2.0-nightly.32 → 1.2.0-nightly.34
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/build-info.json +4 -4
- package/dist/cli.js +88 -31
- package/dist/setup-tunnel-preflight.d.ts +32 -0
- package/dist/setup-tunnel-preflight.js +58 -0
- package/dist/web/auth.d.ts +2 -0
- package/dist/web/auth.js +4 -0
- package/dist/web/prefix-gateway.js +25 -3
- package/dist/web/runtime.js +14 -2
- package/dist/web/server.d.ts +3 -0
- package/dist/web/server.js +52 -1
- package/dist/web-app/version.json +1 -1
- package/dist/workspace-enrollment.d.ts +1 -1
- package/dist/workspace-enrollment.js +1 -1
- package/package.json +1 -1
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.34",
|
|
3
|
+
"buildId": "1d0ffc344d7e",
|
|
4
|
+
"commit": "b17e975b8f21b865f6690fc7299851447b53fdad",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-02T12:17:07.327Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"workspace.enroll.preserve-profile-v1",
|
|
9
9
|
"cowork.http-management-v1",
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { ensureMinimalSetup } from './minimal-setup.js';
|
|
3
3
|
import { configureWorkspacePort } from './workspace-enrollment.js';
|
|
4
|
+
import { clearPendingTunnelSetup, readPendingTunnelSetup, requiredPortAfterExpiry, savePendingTunnelSetup, setupTunnelPreflight } from './setup-tunnel-preflight.js';
|
|
4
5
|
import { availableWebPort } from './web/available-port.js';
|
|
5
6
|
import QRCode from 'qrcode';
|
|
6
7
|
import { readWorkspacePayload, enrollWorkspace } from './workspace-enrollment.js';
|
|
@@ -1534,38 +1535,45 @@ webCommand.command('open').description('securely open or re-pair a browser with
|
|
|
1534
1535
|
});
|
|
1535
1536
|
async function workspaceEnrollmentCommand(opts, setupTunnel = false) {
|
|
1536
1537
|
try {
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
if (
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1538
|
+
if (setupTunnel && opts.check && opts.resume)
|
|
1539
|
+
throw Error('Use either --check or --resume');
|
|
1540
|
+
if (setupTunnel && (opts.check || opts.resume) && (opts.file || opts.migrateAppOrigin))
|
|
1541
|
+
throw Error('--check and --resume take no payload file or migration flag');
|
|
1542
|
+
if (setupTunnel && opts.check) {
|
|
1543
|
+
await setupTunnelPreflight();
|
|
1544
|
+
process.stdout.write('Tunnel setup prerequisites are available.\n');
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
let pending;
|
|
1548
|
+
if (setupTunnel && opts.resume) {
|
|
1549
|
+
pending = readPendingTunnelSetup();
|
|
1550
|
+
if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
|
|
1551
|
+
throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
|
|
1552
|
+
await setupTunnelPreflight();
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
if (!opts.file)
|
|
1556
|
+
throw Error('setup-tunnel requires --file <path> (or --resume to finish an interrupted setup)');
|
|
1557
|
+
const payload = readWorkspacePayload(opts.file);
|
|
1558
|
+
if (setupTunnel) {
|
|
1559
|
+
await setupTunnelPreflight();
|
|
1560
|
+
await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
|
|
1557
1561
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
await
|
|
1562
|
+
const result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
|
|
1563
|
+
pending = { appOrigin: payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: payload.challenge, configuration: resolvePath(opts.configuration ?? defaultConfigPath()) };
|
|
1564
|
+
// The proof is one-time: keep what the remaining steps need so they can be finished without a new command.
|
|
1565
|
+
if (setupTunnel)
|
|
1566
|
+
savePendingTunnelSetup(pending);
|
|
1567
|
+
}
|
|
1568
|
+
try {
|
|
1569
|
+
await finishWorkspaceService(pending, opts.resume ? pending.configuration : opts.configuration, setupTunnel);
|
|
1566
1570
|
}
|
|
1567
|
-
|
|
1568
|
-
|
|
1571
|
+
catch (error) {
|
|
1572
|
+
if (!setupTunnel)
|
|
1573
|
+
throw error;
|
|
1574
|
+
throw Error(`${error instanceof Error ? error.message : String(error)}\nThis host is already bound to the workspace; do not request a new command. Fix the problem above, then run: ours-fleet setup-tunnel --resume`);
|
|
1575
|
+
}
|
|
1576
|
+
clearPendingTunnelSetup();
|
|
1569
1577
|
if (setupTunnel) {
|
|
1570
1578
|
process.stdout.write('Tunnel setup submitted. Check verified binding and tunnel health in the App, then run ours-fleet link-device and paste its private code. Configure Fleet agents and models in the App after linking.\n');
|
|
1571
1579
|
return;
|
|
@@ -1580,13 +1588,62 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false) {
|
|
|
1580
1588
|
die(error);
|
|
1581
1589
|
}
|
|
1582
1590
|
}
|
|
1591
|
+
/** Everything after the root proof: supervised web service, its bound port, and the tunnel target. Safe to repeat. */
|
|
1592
|
+
async function finishWorkspaceService(pending, configuration, record) {
|
|
1593
|
+
if (!existsSync(new WebAccessStore().path))
|
|
1594
|
+
configureWebAccess({ pairing: true });
|
|
1595
|
+
// Once the challenge has expired the account can no longer retarget the tunnel. Decide before touching a running service.
|
|
1596
|
+
const expired = pending.challenge.expiresAt <= Date.now();
|
|
1597
|
+
const requiredPort = expired ? requiredPortAfterExpiry(pending) : undefined;
|
|
1598
|
+
const manager = new WebServiceManager(), previous = manager.readMetadata();
|
|
1599
|
+
if (previous)
|
|
1600
|
+
await manager.stop();
|
|
1601
|
+
const selectedPort = await availableWebPort(requiredPort ?? previous?.port ?? 49271);
|
|
1602
|
+
mkdirSync(joinPath(stateRoot(), 'web'), { recursive: true, mode: 0o700 });
|
|
1603
|
+
writeFileSync(joinPath(stateRoot(), 'web', 'port-selection.json'), JSON.stringify({ expiresAt: Date.now() + 60000 }) + '\n', { mode: 0o600 });
|
|
1604
|
+
const notes = await manager.install(binPath, selectedPort, configuration, { bind: '127.0.0.1', publicOrigin: pending.origin });
|
|
1605
|
+
await manager.restart();
|
|
1606
|
+
// Without linger the user service, and with it the tunnel, stops at logout and does not start at boot.
|
|
1607
|
+
for (const note of notes)
|
|
1608
|
+
if (note.startsWith('warning:'))
|
|
1609
|
+
process.stderr.write(`${note}\nUntil then the tunnel stops when you log out of this host.\n`);
|
|
1610
|
+
const waitForControl = async () => {
|
|
1611
|
+
const controlDeadline = Date.now() + 20000;
|
|
1612
|
+
while (!existsSync(webControlPath())) {
|
|
1613
|
+
if (Date.now() >= controlDeadline)
|
|
1614
|
+
throw Error('Fleet backend did not become ready within 20 seconds. Run ours-fleet web status; on Linux inspect journalctl --user -u ours-fleet-web.service -n 25 --no-pager. Resolve the startup error before running ours-fleet link-device');
|
|
1615
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
await waitForControl();
|
|
1619
|
+
const bound = JSON.parse(readFileSync(joinPath(stateRoot(), 'web', 'workspace-port.json'), 'utf8'));
|
|
1620
|
+
if (!Number.isInteger(bound.port) || bound.port < 1 || bound.port > 65535)
|
|
1621
|
+
throw Error('Invalid workspace listener port');
|
|
1622
|
+
if (bound.port !== selectedPort) {
|
|
1623
|
+
await manager.retainBoundPort(binPath, bound.port, configuration, { bind: '127.0.0.1', publicOrigin: pending.origin });
|
|
1624
|
+
await waitForControl();
|
|
1625
|
+
}
|
|
1626
|
+
if (requiredPort === undefined) {
|
|
1627
|
+
// Record the request first: if its answer is lost, a later resume must not assume the default target.
|
|
1628
|
+
if (record) {
|
|
1629
|
+
pending = { ...pending, requestedPorts: [...(pending.requestedPorts ?? []), bound.port] };
|
|
1630
|
+
savePendingTunnelSetup(pending);
|
|
1631
|
+
}
|
|
1632
|
+
await configureWorkspacePort(pending, pending.hostWorkspaceId, pending.rootCid, bound.port);
|
|
1633
|
+
}
|
|
1634
|
+
else if (bound.port !== requiredPort)
|
|
1635
|
+
throw Error(`The setup window has expired and the tunnel points at port ${requiredPort}, but this host could only bind port ${bound.port}. Free port ${requiredPort} on this host`);
|
|
1636
|
+
rmSync(joinPath(stateRoot(), 'web', 'port-selection.json'), { force: true });
|
|
1637
|
+
}
|
|
1583
1638
|
cOpt(program.command('workspace-enroll').description('enroll this host using a private account setup file'))
|
|
1584
1639
|
.requiredOption('--file <path>', 'owned private payload file')
|
|
1585
1640
|
.option('--preserve-profile', 'retain the existing human profile without creating or rewriting it')
|
|
1586
1641
|
.option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
|
|
1587
1642
|
.action(async (opts) => workspaceEnrollmentCommand(opts));
|
|
1588
1643
|
cOpt(program.command('setup-tunnel').description('connect an installed host using scoped expiring App credentials; preserves identities and agent configuration'))
|
|
1589
|
-
.
|
|
1644
|
+
.option('--file <path>', 'owned private App payload file (chmod 600)')
|
|
1645
|
+
.option('--check', 'only verify host prerequisites; changes nothing and needs no payload')
|
|
1646
|
+
.option('--resume', 'finish a setup that stopped after this host was bound; needs no new payload')
|
|
1590
1647
|
.option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
|
|
1591
1648
|
.action(async (opts) => workspaceEnrollmentCommand(opts, true));
|
|
1592
1649
|
program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Exec } from './exec.js';
|
|
2
|
+
import type { WorkspacePayload } from './workspace-enrollment.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check what the steps after the one-time root proof depend on, before that
|
|
5
|
+
* proof is sent: a failure afterwards cannot be retried with a fresh command.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setupTunnelPreflight({ exec, platform, node }?: {
|
|
8
|
+
exec?: Exec;
|
|
9
|
+
platform?: NodeJS.Platform;
|
|
10
|
+
node?: string;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
/** What the steps after the proof need, kept so they can be finished without a new command. */
|
|
13
|
+
export interface PendingTunnelSetup {
|
|
14
|
+
appOrigin: string;
|
|
15
|
+
origin: string;
|
|
16
|
+
hostWorkspaceId: string;
|
|
17
|
+
rootCid: string;
|
|
18
|
+
challenge: WorkspacePayload['challenge'];
|
|
19
|
+
/** Fleet configuration the setup started with; a resume must use the same one. */
|
|
20
|
+
configuration: string;
|
|
21
|
+
/** Every port sent to the account as the tunnel target. No answer was confirmed while this record exists, so the remote target is any of these or the default. */
|
|
22
|
+
requestedPorts?: number[];
|
|
23
|
+
}
|
|
24
|
+
export declare function savePendingTunnelSetup(pending: PendingTunnelSetup): void;
|
|
25
|
+
export declare function clearPendingTunnelSetup(): void;
|
|
26
|
+
export declare function readPendingTunnelSetup(): PendingTunnelSetup;
|
|
27
|
+
export declare const DEFAULT_TUNNEL_PORT = 49271;
|
|
28
|
+
/**
|
|
29
|
+
* The port the web service must bind when the account can no longer retarget
|
|
30
|
+
* the tunnel. Unknown when a different port was requested and never confirmed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function requiredPortAfterExpiry(pending: PendingTunnelSetup): number;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from './atomic-file.js';
|
|
5
|
+
import { realExec } from './exec.js';
|
|
6
|
+
import { stateRoot } from './paths.js';
|
|
7
|
+
const UNCHANGED = 'Nothing was changed and this host was not bound.';
|
|
8
|
+
/**
|
|
9
|
+
* Check what the steps after the one-time root proof depend on, before that
|
|
10
|
+
* proof is sent: a failure afterwards cannot be retried with a fresh command.
|
|
11
|
+
*/
|
|
12
|
+
export async function setupTunnelPreflight({ exec = realExec, platform = process.platform, node = process.versions.node } = {}) {
|
|
13
|
+
const [major, minor] = node.split('.').map(Number);
|
|
14
|
+
if (major < 22 || (major === 22 && minor < 13))
|
|
15
|
+
throw Error(`Tunnel setup requires Node.js 22.13 or newer; this is ${node}. Update Node.js, then run a fresh tunnel setup command. ${UNCHANGED}`);
|
|
16
|
+
if (platform === 'linux') {
|
|
17
|
+
const manager = await exec('systemctl', ['--user', 'show-environment'], { timeout: 10_000 });
|
|
18
|
+
if (manager.code !== 0)
|
|
19
|
+
throw Error(`The user service manager is not reachable from this shell (${manager.stderr.trim().split('\n')[0] || `systemctl --user exited ${manager.code}`}). `
|
|
20
|
+
+ `Log in directly as ${userInfo().username} (not through su or sudo), or run: sudo loginctl enable-linger ${userInfo().username} && export XDG_RUNTIME_DIR=/run/user/${process.getuid?.()} `
|
|
21
|
+
+ `Then run a fresh tunnel setup command. ${UNCHANGED}`);
|
|
22
|
+
}
|
|
23
|
+
const help = await exec('cloudflared', ['tunnel', 'run', '--help'], { timeout: 10_000 });
|
|
24
|
+
if (help.code === 127)
|
|
25
|
+
throw Error(`cloudflared is not installed or not on PATH. Install it, then run a fresh tunnel setup command. ${UNCHANGED}`);
|
|
26
|
+
if (help.code !== 0 || !`${help.stdout}\n${help.stderr}`.includes('--token-file'))
|
|
27
|
+
throw Error(`This cloudflared is too old for tunnel setup (no --token-file support). Update cloudflared, then run a fresh tunnel setup command. ${UNCHANGED}`);
|
|
28
|
+
}
|
|
29
|
+
const pendingPath = () => join(stateRoot(), 'workspace', 'pending-setup.json');
|
|
30
|
+
export function savePendingTunnelSetup(pending) {
|
|
31
|
+
replaceFileAtomically(pendingPath(), JSON.stringify(pending) + '\n', 0o600);
|
|
32
|
+
}
|
|
33
|
+
export function clearPendingTunnelSetup() { rmSync(pendingPath(), { force: true }); }
|
|
34
|
+
export function readPendingTunnelSetup() {
|
|
35
|
+
const path = pendingPath();
|
|
36
|
+
if (!existsSync(path))
|
|
37
|
+
throw Error('No unfinished tunnel setup on this host. Run the tunnel setup command from the App.');
|
|
38
|
+
const stat = lstatSync(path);
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0)
|
|
40
|
+
throw Error('Unfinished tunnel setup record must be an owned private file');
|
|
41
|
+
const pending = JSON.parse(readFileSync(path, 'utf8'));
|
|
42
|
+
if (typeof pending?.appOrigin !== 'string' || typeof pending.origin !== 'string' || typeof pending.hostWorkspaceId !== 'string'
|
|
43
|
+
|| typeof pending.rootCid !== 'string' || !pending.challenge || !Number.isSafeInteger(pending.challenge.expiresAt) || typeof pending.configuration !== 'string'
|
|
44
|
+
|| (pending.requestedPorts !== undefined && (!Array.isArray(pending.requestedPorts) || !pending.requestedPorts.every(Number.isInteger))))
|
|
45
|
+
throw Error('Unfinished tunnel setup record is invalid');
|
|
46
|
+
return pending;
|
|
47
|
+
}
|
|
48
|
+
export const DEFAULT_TUNNEL_PORT = 49_271;
|
|
49
|
+
/**
|
|
50
|
+
* The port the web service must bind when the account can no longer retarget
|
|
51
|
+
* the tunnel. Unknown when a different port was requested and never confirmed.
|
|
52
|
+
*/
|
|
53
|
+
export function requiredPortAfterExpiry(pending) {
|
|
54
|
+
const other = [...new Set(pending.requestedPorts ?? [])].filter(port => port !== DEFAULT_TUNNEL_PORT);
|
|
55
|
+
if (!other.length)
|
|
56
|
+
return DEFAULT_TUNNEL_PORT;
|
|
57
|
+
throw Error(`The setup window has expired and it is unknown whether the tunnel points at port ${[...other, DEFAULT_TUNNEL_PORT].join(' or ')}: the account's answer to a port request was not received. This setup cannot be finished automatically`);
|
|
58
|
+
}
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export declare class WebAuth {
|
|
|
52
52
|
}): void;
|
|
53
53
|
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
54
54
|
mintBootstrap(): string;
|
|
55
|
+
/** The configured account origin, when this request comes from it and workspace devices are enabled. */
|
|
56
|
+
accountOrigin(request: Pick<FastifyRequest, 'headers'>): string | undefined;
|
|
55
57
|
validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
|
|
56
58
|
exchange(request: FastifyRequest): AuthResult;
|
|
57
59
|
login(request: FastifyRequest, password: string): AuthResult;
|
package/dist/web/auth.js
CHANGED
|
@@ -54,6 +54,10 @@ export class WebAuth {
|
|
|
54
54
|
this.bootstrapUsed = false;
|
|
55
55
|
return this._bootstrapSecret;
|
|
56
56
|
}
|
|
57
|
+
/** The configured account origin, when this request comes from it and workspace devices are enabled. */
|
|
58
|
+
accountOrigin(request) {
|
|
59
|
+
return this.workspaceDevices && request.headers.origin === this.appOrigin ? this.appOrigin : undefined;
|
|
60
|
+
}
|
|
57
61
|
validateBoundary(request, requireOrigin, allowPageNavigation = false) {
|
|
58
62
|
const host = request.headers.host;
|
|
59
63
|
const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
|
|
@@ -72,14 +72,31 @@ export function createPrefixGateway(options) {
|
|
|
72
72
|
timer.unref();
|
|
73
73
|
return () => { clearInterval(timer); unbind(); };
|
|
74
74
|
};
|
|
75
|
+
// The account App calls workspace services directly from its own origin with a device
|
|
76
|
+
// credential. Only that exact configured origin is answered; the reply grants nothing by
|
|
77
|
+
// itself, every request is still authenticated below.
|
|
78
|
+
const accountCors = (req) => {
|
|
79
|
+
const origin = auth?.accountOrigin(req);
|
|
80
|
+
return origin ? { 'access-control-allow-origin': origin, vary: 'Origin',
|
|
81
|
+
'access-control-expose-headers': 'Accept-Ranges, Content-Disposition, Content-Length, Content-Range, Content-Type, ETag' } : {};
|
|
82
|
+
};
|
|
75
83
|
const server = createServer((req, res) => {
|
|
84
|
+
const cors = accountCors(req);
|
|
76
85
|
let route;
|
|
77
86
|
try {
|
|
87
|
+
if (req.method === 'OPTIONS' && cors.vary && req.headers['access-control-request-method'] && select(req).service) {
|
|
88
|
+
auth.validateBoundary({ headers: req.headers, method: req.method, url: req.url }, false);
|
|
89
|
+
res.writeHead(204, { ...cors, 'cache-control': 'no-store', 'access-control-max-age': '600',
|
|
90
|
+
'access-control-allow-methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
91
|
+
'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match, Range, X-CSRF-Token, X-Ours-Messenger-CSRF, X-Voice-Duration' });
|
|
92
|
+
res.end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
78
95
|
route = prepare(req);
|
|
79
96
|
}
|
|
80
97
|
catch (error) {
|
|
81
98
|
const code = error.code;
|
|
82
|
-
res.writeHead(code === 'unauthorized' ? 401 : 403, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
99
|
+
res.writeHead(code === 'unauthorized' ? 401 : 403, { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
83
100
|
res.end(JSON.stringify({ error: { code: code ?? 'forbidden', message: error.message } }));
|
|
84
101
|
return;
|
|
85
102
|
}
|
|
@@ -92,8 +109,13 @@ export function createPrefixGateway(options) {
|
|
|
92
109
|
method: req.method, path: route.path, headers: route.headers,
|
|
93
110
|
}, response => {
|
|
94
111
|
const headers = headersFor(response);
|
|
95
|
-
if (route.service)
|
|
112
|
+
if (route.service) {
|
|
96
113
|
delete headers['set-cookie'];
|
|
114
|
+
for (const key of Object.keys(headers))
|
|
115
|
+
if (key.startsWith('access-control-'))
|
|
116
|
+
delete headers[key];
|
|
117
|
+
Object.assign(headers, cors);
|
|
118
|
+
}
|
|
97
119
|
res.writeHead(response.statusCode ?? 502, headers);
|
|
98
120
|
response.on('error', () => res.destroy());
|
|
99
121
|
response.on('aborted', () => res.destroy());
|
|
@@ -104,7 +126,7 @@ export function createPrefixGateway(options) {
|
|
|
104
126
|
});
|
|
105
127
|
upstream.on('error', () => {
|
|
106
128
|
if (!res.headersSent)
|
|
107
|
-
res.writeHead(502, { 'content-type': 'application/json' });
|
|
129
|
+
res.writeHead(502, { ...(route.service ? cors : {}), 'content-type': 'application/json' });
|
|
108
130
|
res.end(JSON.stringify({ error: { code: 'upstream_unavailable', message: 'Service is unavailable' } }));
|
|
109
131
|
});
|
|
110
132
|
const untrack = track(req, route.session, () => { upstream.destroy(); res.destroy(); });
|
package/dist/web/runtime.js
CHANGED
|
@@ -10,7 +10,7 @@ import { spawn } from 'node:child_process';
|
|
|
10
10
|
import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
|
|
11
11
|
import { producerConfig } from '../notifications/outbox.js';
|
|
12
12
|
import { SubscriptionService } from '../subscriptions/service.js';
|
|
13
|
-
import { existsSync, realpathSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, realpathSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { resolve } from 'node:path';
|
|
15
15
|
import { loadConfig } from '../config.js';
|
|
16
16
|
import { RoleRepository } from '../application/role-repository.js';
|
|
@@ -24,7 +24,8 @@ import { FleetError } from '../application/errors.js';
|
|
|
24
24
|
import { controlRequest, controlSocketPath } from '../session/control.js';
|
|
25
25
|
import { pickBackend } from '../supervisor/index.js';
|
|
26
26
|
import { realExec } from '../exec.js';
|
|
27
|
-
import { home, stateRoot, agentsRoot, tmpRoot } from '../paths.js';
|
|
27
|
+
import { home, stateRoot, agentsRoot, tmpRoot, logsRoot } from '../paths.js';
|
|
28
|
+
import { executeInitAnswers, publishSetup } from '../init-wizard.js';
|
|
28
29
|
import { AuditSink } from './audit.js';
|
|
29
30
|
import { FleetEventBus } from './events.js';
|
|
30
31
|
import { buildWebServer } from './server.js';
|
|
@@ -190,6 +191,17 @@ export async function startWebConsole(options) {
|
|
|
190
191
|
layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
|
|
191
192
|
presetProvenance: new PresetProvenance(options.configPath),
|
|
192
193
|
topology: readTopology, topologyDrafts, topologyPromote,
|
|
194
|
+
// Same boundary as `ours-fleet init --settings`: existing files are preserved, missing defaults are added.
|
|
195
|
+
onboardingSetup: async (answers) => {
|
|
196
|
+
await executeInitAnswers(answers, configuration.path, {
|
|
197
|
+
async hostSetup() {
|
|
198
|
+
for (const directory of [agentsRoot(), tmpRoot(), logsRoot()])
|
|
199
|
+
mkdirSync(directory, { recursive: true });
|
|
200
|
+
await pickBackend().init(options.binPath);
|
|
201
|
+
},
|
|
202
|
+
publish: publishSetup,
|
|
203
|
+
});
|
|
204
|
+
},
|
|
193
205
|
subscriptions: new SubscriptionService({
|
|
194
206
|
agents: async () => (await query.list(true)).map(({ role, status }) => ({
|
|
195
207
|
roleId: role.id, stateDir: repository.stateDir(role), running: status.supervisor.liveness === 'running',
|
package/dist/web/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type InitAnswers } from '../init-wizard.js';
|
|
1
2
|
import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
|
|
2
3
|
import { type FastifyInstance } from 'fastify';
|
|
3
4
|
import type { FleetQueryService } from '../application/fleet-query-service.js';
|
|
@@ -41,6 +42,8 @@ export interface WebServices {
|
|
|
41
42
|
presetProvenance?: PresetProvenance;
|
|
42
43
|
oursTools?: Pick<SupervisorOursTools, 'list' | 'call'>;
|
|
43
44
|
subscriptions?: SubscriptionService;
|
|
45
|
+
/** First-time setup from the packaged presets; the account App supplies only the model choices. */
|
|
46
|
+
onboardingSetup?(answers: InitAnswers): Promise<void>;
|
|
44
47
|
}
|
|
45
48
|
export interface WebServer {
|
|
46
49
|
app: FastifyInstance;
|
package/dist/web/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { ROLE_NAME_RE } from '../config.js';
|
|
|
22
22
|
import { TaskListError } from '../rooms-tasks/task-lists.js';
|
|
23
23
|
import { TaskStateError } from '../rooms-tasks/task-state.js';
|
|
24
24
|
import { isProvider } from '../subscriptions/store.js';
|
|
25
|
+
const ONBOARDING_WORK = ['coordination', 'development', 'review'];
|
|
25
26
|
const statusFor = (code) => ({
|
|
26
27
|
role_not_found: 404, resource_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
|
|
27
28
|
idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
|
|
@@ -165,7 +166,57 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
165
166
|
}
|
|
166
167
|
}));
|
|
167
168
|
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
|
+
return { harnesses, catalog: catalog.models, providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
|
|
170
|
+
});
|
|
171
|
+
// First-time setup is what creates the persistent coordinator. A configuration that loads and
|
|
172
|
+
// has it is set up, whatever its brains are called; its models are then changed by editing it.
|
|
173
|
+
const onboardingConfigured = () => {
|
|
174
|
+
if (!services.configuration)
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
return Boolean(services.configuration.read(false).model.agents.FleetCoordinator);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
let onboardingSetupQueue = Promise.resolve();
|
|
184
|
+
app.post('/api/v1/onboarding/setup', async (request) => {
|
|
185
|
+
auth.authenticate(request, true);
|
|
186
|
+
if (!services.configuration || !services.onboardingSetup)
|
|
187
|
+
throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
|
|
188
|
+
const requested = request.body?.models;
|
|
189
|
+
const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models;
|
|
190
|
+
const models = {};
|
|
191
|
+
for (const work of ONBOARDING_WORK) {
|
|
192
|
+
const matches = supported.filter(model => model.harness === requested?.[work]?.harness && model.model === requested?.[work]?.model);
|
|
193
|
+
if (matches.length !== 1)
|
|
194
|
+
throw new FleetError('invalid_request', `choose a supported model for ${work}`);
|
|
195
|
+
models[work] = matches[0];
|
|
196
|
+
}
|
|
197
|
+
const chosen = Object.values(models);
|
|
198
|
+
const subscriptions = [...new Set(chosen.map(model => model.harness === 'codex' ? 'codex' : 'claude'))];
|
|
199
|
+
const oneModel = new Set(chosen.map(model => `${model.harness}\0${model.model}`)).size === 1;
|
|
200
|
+
const configuration = services.configuration, setup = services.onboardingSetup;
|
|
201
|
+
// One setup at a time: the first-time check and the publication it guards must not interleave.
|
|
202
|
+
const run = onboardingSetupQueue.then(async () => {
|
|
203
|
+
if (onboardingConfigured())
|
|
204
|
+
throw new FleetError('conflict', 'Fleet is already set up; change its models in the configuration');
|
|
205
|
+
try {
|
|
206
|
+
await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models, reasoning: 'balanced' });
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
// Setup preserves files it finds, so confirm the published models are the ones this request chose.
|
|
212
|
+
const brains = configuration.read(true).model.brains ?? {};
|
|
213
|
+
for (const work of ONBOARDING_WORK)
|
|
214
|
+
if (brains[work]?.harness !== models[work].harness || brains[work]?.model !== models[work].model)
|
|
215
|
+
throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
|
|
216
|
+
return { configured: true };
|
|
217
|
+
});
|
|
218
|
+
onboardingSetupQueue = run.catch(() => { });
|
|
219
|
+
return run;
|
|
169
220
|
});
|
|
170
221
|
app.get('/api/v1/creation-capabilities', async (request) => {
|
|
171
222
|
auth.authenticate(request);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sha":"21b1610c125e41982803ecc8c389c169dd1252c4","time":"2026-10-
|
|
1
|
+
{"sha":"21b1610c125e41982803ecc8c389c169dd1252c4","time":"2026-10-02T12:14:07.133Z"}
|
|
@@ -25,4 +25,4 @@ export declare function enrollWorkspace(payload: WorkspacePayload, configuration
|
|
|
25
25
|
rootCid: any;
|
|
26
26
|
}>;
|
|
27
27
|
/** Only the existing signed-root receipt authorizes the scoped tunnel target. */
|
|
28
|
-
export declare function configureWorkspacePort(payload: WorkspacePayload, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
|
|
28
|
+
export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
|
|
@@ -33,7 +33,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
33
33
|
if (!base.pathname.endsWith('/messenger/'))
|
|
34
34
|
throw Error('Workspace setup requires the supported gateway client profile');
|
|
35
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) {
|
|
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(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
|
|
37
37
|
await response.body?.cancel();
|
|
38
38
|
throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
|
|
39
39
|
} return response.json(); };
|
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.34",
|
|
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",
|