@ours.network/fleet 1.2.0-nightly.32 → 1.2.0-nightly.33

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.32",
3
- "buildId": "199550205158",
4
- "commit": "22c8e5fe504d1ac013dfcce0269a46a9ca6a66b6",
2
+ "version": "1.2.0-nightly.33",
3
+ "buildId": "c27262dcfdeb",
4
+ "commit": "57b2fa27b72ae4f53fbad81e966139474b7a6e03",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-02T08:03:30.874Z",
6
+ "builtAt": "2026-10-02T10:40:46.562Z",
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
- const payload = readWorkspacePayload(opts.file);
1538
- if (setupTunnel)
1539
- await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
1540
- const result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1541
- if (!existsSync(new WebAccessStore().path))
1542
- configureWebAccess({ pairing: true });
1543
- const manager = new WebServiceManager(), previous = manager.readMetadata();
1544
- if (previous)
1545
- await manager.stop();
1546
- const selectedPort = await availableWebPort(previous?.port ?? 49271);
1547
- mkdirSync(joinPath(stateRoot(), 'web'), { recursive: true, mode: 0o700 });
1548
- writeFileSync(joinPath(stateRoot(), 'web', 'port-selection.json'), JSON.stringify({ expiresAt: Date.now() + 60000 }) + '\n', { mode: 0o600 });
1549
- await manager.install(binPath, selectedPort, opts.configuration, { bind: '127.0.0.1', publicOrigin: result.origin });
1550
- await manager.restart();
1551
- const waitForControl = async () => {
1552
- const controlDeadline = Date.now() + 20000;
1553
- while (!existsSync(webControlPath())) {
1554
- if (Date.now() >= controlDeadline)
1555
- 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');
1556
- await new Promise(resolve => setTimeout(resolve, 200));
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
- await waitForControl();
1560
- const bound = JSON.parse(readFileSync(joinPath(stateRoot(), 'web', 'workspace-port.json'), 'utf8'));
1561
- if (!Number.isInteger(bound.port) || bound.port < 1 || bound.port > 65535)
1562
- throw Error('Invalid workspace listener port');
1563
- if (bound.port !== selectedPort) {
1564
- await manager.retainBoundPort(binPath, bound.port, opts.configuration, { bind: '127.0.0.1', publicOrigin: result.origin });
1565
- await waitForControl();
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
- await configureWorkspacePort(payload, result.hostWorkspaceId, result.rootCid, bound.port);
1568
- rmSync(joinPath(stateRoot(), 'web', 'port-selection.json'), { force: true });
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
- .requiredOption('--file <path>', 'owned private App payload file (chmod 600)')
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
+ }
@@ -1 +1 @@
1
- {"sha":"21b1610c125e41982803ecc8c389c169dd1252c4","time":"2026-10-02T08:00:39.873Z"}
1
+ {"sha":"21b1610c125e41982803ecc8c389c169dd1252c4","time":"2026-10-02T10:38:31.229Z"}
@@ -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.32",
3
+ "version": "1.2.0-nightly.33",
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",