@ours.network/fleet 2.1.0 → 2.1.2

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 CHANGED
@@ -100,7 +100,8 @@ The maintained Codex and Claude ACP adapters remain bundled optional dependencie
100
100
  and are resolved internally; users do not install adapter commands or add them to
101
101
  `PATH`. Explicit `session_options.codex_app_server.command` and
102
102
  `session_options.acp.command` overrides remain available. ours-fleet and its
103
- maintained adapters require Node 22 or newer.
103
+ maintained adapters require a patched Node.js 22.23.4+, 24.22.1+ or 26.12.1+
104
+ release (see [Security version requirements](#security-version-requirements)).
104
105
 
105
106
  Each OS user manages their own fleet — to host roles under a sandboxed account,
106
107
  become that account and repeat.
@@ -1737,7 +1738,7 @@ These package checks do not replace native Fleet/harness or platform acceptance.
1737
1738
 
1738
1739
  ### Build with selected SDK and CLI archives
1739
1740
 
1740
- Run in the build container with Node 22+, npm and tar available:
1741
+ Run in the build container with a patched Node.js (22.23.4+, 24.22.1+ or 26.12.1+), npm and tar available:
1741
1742
 
1742
1743
  ```sh
1743
1744
  node scripts/build-selected.mjs --sdk /artifacts/ours.network-sdk-3.7.2.tgz --cli /artifacts/ours.network-cli-2.7.2.tgz --out-dir /artifacts/consumer
@@ -1971,3 +1972,41 @@ existing role/default configuration when applying this change. Meaningful agent
1971
1972
  commentary, attention requests and final answers continue to be delivered.
1972
1973
  The app associates activity with the channel's bound CID, never a display name.
1973
1974
  It shows unavailable status when the directory observation disconnects.
1975
+
1976
+ ## Security version requirements
1977
+
1978
+ Ours refuses to run on Node.js or cloudflared releases with known security
1979
+ defects that affect it. The checks run before setup changes anything and
1980
+ again before every tunnel start, and the refusal names the minimum version,
1981
+ the version found and where to update.
1982
+
1983
+ **Node.js.** Only reviewed stable release lines are accepted, each with its
1984
+ own patched floor: 22 (22.23.4+), 24 (24.22.1+) and 26 (26.12.1+). Earlier
1985
+ patch levels of these lines, end-of-life lines (20, 21, 23, 25), prerelease
1986
+ or nightly builds and release lines that have not been reviewed yet (such as
1987
+ 27) are refused. A new line is admitted by a reviewed change that adds its
1988
+ floor, never by a single global "newer than" comparison. The
1989
+ [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
1990
+ fix CVE-2026-111497: the bundled Brotli decoder can write out of bounds when
1991
+ decompressing attacker-controlled data, which can cause memory corruption,
1992
+ crashes, information disclosure and potentially remote code execution.
1993
+ Update from [nodejs.org](https://nodejs.org/en/download) or your package
1994
+ manager. The `engines` field in `package.json` records the same floors but
1995
+ is advisory; the runtime checks enforce them.
1996
+
1997
+ **cloudflared.** A tunnel requires cloudflared **2026.10.2 or later**, as
1998
+ reported by `cloudflared --version` for the executable found on the PATH of
1999
+ the process that starts the tunnel. Older, prerelease, unrecognized, failing,
2000
+ missing or timed-out version checks are refused.
2001
+ [Cloudflare's eMudhra incident explanation](https://blog.cloudflare.com/emudhra-certificate-compromise/)
2002
+ describes why: earlier releases trust compromised eMudhra intermediate
2003
+ certificates, which allows server impersonation. Update with your package
2004
+ manager or
2005
+ [Cloudflare's update instructions](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/update-cloudflared/),
2006
+ then check `cloudflared --version` again.
2007
+
2008
+ These checks do not install or upgrade prerequisites, disable TLS
2009
+ verification, rotate credentials or restart services. A process that is
2010
+ already running keeps the binaries it started with: update Node.js and
2011
+ cloudflared, then restart the affected services (and rebuild or recreate
2012
+ containers) yourself.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.1.0",
3
- "buildId": "697e8bf9e53c",
4
- "commit": "2805e0e84c2a3bad05efdbe895fd8fb6e9d2aff4",
2
+ "version": "2.1.2",
3
+ "buildId": "5bf10a772912",
4
+ "commit": "dd8adad553ca71b0edae5cc1040eacaaed51e50a",
5
5
  "dirty": false,
6
- "builtAt": "2026-10-27T12:22:22.859Z",
6
+ "builtAt": "2026-10-28T20:22:31.826Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './runtime-security.js';
2
3
  import './harness/claude-code.js';
3
4
  import './harness/codex.js';
4
5
  import './harness/hermes.js';
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './runtime-security.js';
2
3
  import { beginWorkspaceReplacement, abandonWorkspaceReplacement, discardRetiredWorkspaceReplacement, clearWorkspaceReplacement, confirmWorkspaceReplacement, finishWorkspaceReplacement, readWorkspaceBinding, readWorkspaceReplacement, requiresWorkspaceReplacement, refreshWorkspaceReplacement } from './workspace-replacement.js';
3
4
  import { completeTunnelSetup } from './tunnel-setup-completion.js';
4
5
  import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup, inspectTunnelSetup, verifyTunnelSetupTarget } from './tunnel-setup-input.js';
package/dist/doctor.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { cloudflaredUnavailableError, cloudflaredVersionError, CLOUDFLARED_VERSION_TIMEOUT_MS, isSecureCloudflaredVersion, isSecureNodeVersion, nodeVersionError, parseCloudflaredVersion } from './security-versions.js';
1
2
  import { describeRegistration, refreshServiceRegistrations, registrationDrift } from './supervisor/registration-refresh.js';
3
+ import { join } from 'node:path';
2
4
  import { userInfo } from 'node:os';
3
5
  import { existsSync, readFileSync } from 'node:fs';
4
6
  import { attachOursClient, } from '@ours.network/sdk/client';
@@ -8,7 +10,7 @@ import { getAdapter, productionAdapters } from './harness/registry.js';
8
10
  import { analyzeFleetPermissions, formatNative } from './permissions.js';
9
11
  import { managedCliReport } from './managed-cli.js';
10
12
  import { resolveBundledAcpAgent } from './harness/acp-agent.js';
11
- import { agentDir, deriveXdgRuntimeDir } from './paths.js';
13
+ import { agentDir, deriveXdgRuntimeDir, stateRoot } from './paths.js';
12
14
  import { resolveIsolation, harnessRuntimeDir } from './isolation/policy.js';
13
15
  import { hermesConfiguredProvider } from './harness/hermes-startup.js';
14
16
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
@@ -116,11 +118,22 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
116
118
  detail: `could not inspect: ${error?.message ?? String(error)}` });
117
119
  }
118
120
  }
119
- const major = Number(process.versions.node.split('.')[0]);
121
+ const secureNode = isSecureNodeVersion(process.versions.node);
120
122
  checks.push({
121
- name: 'node', ok: major >= 20,
122
- detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
123
+ name: 'node', ok: secureNode,
124
+ detail: secureNode ? `v${process.versions.node}` : nodeVersionError(process.versions.node),
123
125
  });
126
+ // A configured workspace tunnel starts the cloudflared found on PATH.
127
+ if (existsSync(join(stateRoot(), 'workspace', 'tunnel.json'))) {
128
+ const connector = await exec('cloudflared', ['--version'], { timeout: CLOUDFLARED_VERSION_TIMEOUT_MS });
129
+ const secure = connector.code === 0 && isSecureCloudflaredVersion(connector.stdout);
130
+ checks.push({
131
+ name: 'cloudflared', ok: secure,
132
+ detail: secure ? `version ${parseCloudflaredVersion(connector.stdout).join('.')}`
133
+ : connector.code !== 0 ? cloudflaredUnavailableError(connector.code === 127 ? 'not found' : 'version check failed')
134
+ : cloudflaredVersionError(connector.stdout),
135
+ });
136
+ }
124
137
  // The configuration is a checked prerequisite in its own right. A config the
125
138
  // `config` command rejects must fail here too, with the same cause — while the
126
139
  // host checks below still run, because they are what the operator needs next.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ // Imported first by the CLI entry point: refuse an unpatched Node.js before any other module runs.
2
+ import { isSecureNodeVersion, nodeVersionError } from './security-versions.js';
3
+ if (!isSecureNodeVersion(process.versions.node)) {
4
+ process.stderr.write(`${nodeVersionError(process.versions.node)}\n`);
5
+ process.exit(1);
6
+ }
@@ -0,0 +1,20 @@
1
+ export declare const NODE_PATCHED_FLOORS: Readonly<Record<number, readonly [number, number, number]>>;
2
+ export declare const NODE_SECURITY_ADVISORY = "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases";
3
+ export declare const NODE_DOWNLOAD_URL = "https://nodejs.org/en/download";
4
+ export declare const NODE_SECURITY_REQUIREMENT: string;
5
+ export declare const CLOUDFLARED_MIN_VERSION: readonly number[];
6
+ export declare const CLOUDFLARED_SECURITY_ADVISORY = "https://blog.cloudflare.com/emudhra-certificate-compromise/";
7
+ export declare const CLOUDFLARED_UPDATE_URL = "https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/update-cloudflared/";
8
+ export declare const CLOUDFLARED_VERSION_TIMEOUT_MS = 10000;
9
+ /** Returns [major, minor, patch] for a stable Node.js version string, otherwise null. */
10
+ export declare function parseNodeVersion(version: string): number[] | null;
11
+ export declare function isSecureNodeVersion(version: string): boolean;
12
+ export declare function nodeVersionError(version: string): string;
13
+ export declare function assertSecureNodeVersion(version?: string): void;
14
+ /** Returns [year, month, patch] for exact stable `cloudflared --version` output, otherwise null. */
15
+ export declare function parseCloudflaredVersion(output: string): number[] | null;
16
+ /** `reason` must be a fixed description such as "not found" or "timed out", never command output. */
17
+ export declare function cloudflaredUnavailableError(reason: string): string;
18
+ export declare function cloudflaredVersionError(output: string): string;
19
+ export declare function isSecureCloudflaredVersion(output: string): boolean;
20
+ export declare function assertSecureCloudflaredVersion(output: string): void;
@@ -0,0 +1,70 @@
1
+ // Security version floors for the runtimes Ours executes. Keep this file
2
+ // identical in ours-network, ours-fleet and ours-app.
3
+ //
4
+ // Node.js policy: only reviewed stable release lines are admitted, each with
5
+ // its own patched floor. A new line (for example a future 27 or 28 release) is
6
+ // added by a reviewed change once its security releases have been checked;
7
+ // until then it is refused, as are prerelease builds, end-of-life lines and
8
+ // earlier patch levels of a listed line. A single global ">=" comparison would
9
+ // admit vulnerable intermediate lines, so every line keeps an explicit floor.
10
+ export const NODE_PATCHED_FLOORS = Object.freeze({ 22: [22, 23, 4], 24: [24, 22, 1], 26: [26, 12, 1] });
11
+ export const NODE_SECURITY_ADVISORY = 'https://nodejs.org/en/blog/vulnerability/october-2026-security-releases';
12
+ export const NODE_DOWNLOAD_URL = 'https://nodejs.org/en/download';
13
+ export const NODE_SECURITY_REQUIREMENT = Object.values(NODE_PATCHED_FLOORS).map(floor => `${floor.join('.')}+`).join(', ');
14
+ // cloudflared releases before 2026.10.2 trust compromised eMudhra intermediates.
15
+ export const CLOUDFLARED_MIN_VERSION = Object.freeze([2026, 10, 2]);
16
+ export const CLOUDFLARED_SECURITY_ADVISORY = 'https://blog.cloudflare.com/emudhra-certificate-compromise/';
17
+ export const CLOUDFLARED_UPDATE_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/update-cloudflared/';
18
+ export const CLOUDFLARED_VERSION_TIMEOUT_MS = 10_000;
19
+ const atLeast = (version, floor) => {
20
+ for (let i = 0; i < floor.length; i++)
21
+ if (version[i] !== floor[i])
22
+ return version[i] > floor[i];
23
+ return true;
24
+ };
25
+ /** Returns [major, minor, patch] for a stable Node.js version string, otherwise null. */
26
+ export function parseNodeVersion(version) {
27
+ const match = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})$/.exec(String(version));
28
+ return match ? match.slice(1).map(Number) : null;
29
+ }
30
+ export function isSecureNodeVersion(version) {
31
+ const parsed = parseNodeVersion(version);
32
+ const floor = parsed && NODE_PATCHED_FLOORS[parsed[0]];
33
+ return Boolean(floor && atLeast(parsed, floor));
34
+ }
35
+ export function nodeVersionError(version) {
36
+ const shown = /^v?[0-9A-Za-z.+-]{1,64}$/.test(String(version)) ? String(version).replace(/^v/, '') : 'unrecognized';
37
+ return `Node.js ${shown} is not permitted. Ours requires a patched supported Node.js release: ${NODE_SECURITY_REQUIREMENT} `
38
+ + 'within those release lines; prerelease, end-of-life and unreviewed release lines are refused. '
39
+ + `Earlier patch levels are affected by CVE-2026-111497 (Brotli decompression out-of-bounds write) and the other October 2026 fixes: ${NODE_SECURITY_ADVISORY} `
40
+ + `Update Node.js from ${NODE_DOWNLOAD_URL} or your package manager, then retry.`;
41
+ }
42
+ export function assertSecureNodeVersion(version = process.versions.node) {
43
+ if (!isSecureNodeVersion(version))
44
+ throw new Error(nodeVersionError(version));
45
+ }
46
+ /** Returns [year, month, patch] for exact stable `cloudflared --version` output, otherwise null. */
47
+ export function parseCloudflaredVersion(output) {
48
+ const match = /^cloudflared version (\d{4})\.(\d{1,2})\.(\d{1,6})(?: \(built [^\r\n()]*\))?\r?\n?$/.exec(String(output));
49
+ const parsed = match?.slice(1).map(Number);
50
+ return parsed && parsed[1] >= 1 && parsed[1] <= 12 ? parsed : null;
51
+ }
52
+ const cloudflaredGuidance = () => `cloudflared releases before ${CLOUDFLARED_MIN_VERSION.join('.')} trust compromised eMudhra intermediate certificates, which allows server impersonation: ${CLOUDFLARED_SECURITY_ADVISORY} `
53
+ + `Update the cloudflared executable on this host's PATH (${CLOUDFLARED_UPDATE_URL}, or your package manager), check it with "cloudflared --version", then retry.`;
54
+ /** `reason` must be a fixed description such as "not found" or "timed out", never command output. */
55
+ export function cloudflaredUnavailableError(reason) {
56
+ return `cloudflared could not be verified (${reason}). A tunnel requires cloudflared ${CLOUDFLARED_MIN_VERSION.join('.')} or later. ${cloudflaredGuidance()}`;
57
+ }
58
+ export function cloudflaredVersionError(output) {
59
+ const parsed = parseCloudflaredVersion(output);
60
+ const found = parsed ? `version ${parsed.join('.')}` : 'with an unrecognized version';
61
+ return `cloudflared ${found} is not permitted; a tunnel requires cloudflared ${CLOUDFLARED_MIN_VERSION.join('.')} or later. ${cloudflaredGuidance()}`;
62
+ }
63
+ export function isSecureCloudflaredVersion(output) {
64
+ const parsed = parseCloudflaredVersion(output);
65
+ return Boolean(parsed && atLeast(parsed, CLOUDFLARED_MIN_VERSION));
66
+ }
67
+ export function assertSecureCloudflaredVersion(output) {
68
+ if (!isSecureCloudflaredVersion(output))
69
+ throw new Error(cloudflaredVersionError(output));
70
+ }
@@ -699,13 +699,27 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
699
699
  const socket = createConnection(controlSocketPath(stateDir));
700
700
  socket.setEncoding('utf8');
701
701
  return new Promise((resolve, reject) => {
702
+ let settled = false, expiry;
703
+ const settle = () => { settled = true; clearTimeout(timer); if (expiry)
704
+ clearImmediate(expiry); };
705
+ // Timers run before pending I/O. When this process was busy past the deadline, a reply
706
+ // already waiting on the socket is still unread: give it the next poll phase before
707
+ // declaring the supervisor silent. That is one event-loop turn, not added time; a reply
708
+ // arriving within that turn is accepted, and a silent supervisor fails in the same turn.
702
709
  const timer = setTimeout(() => {
703
- socket.destroy();
704
- reject(new SessionControlError('timeout', `the role control plane did not answer '${request.command}' within ${timeoutMs}ms`));
710
+ expiry = setImmediate(() => {
711
+ if (settled)
712
+ return;
713
+ settle();
714
+ socket.destroy();
715
+ reject(new SessionControlError('timeout', `the role control plane did not answer '${request.command}' within ${timeoutMs}ms`));
716
+ });
705
717
  }, timeoutMs);
706
718
  let buffer = '';
707
719
  socket.once('error', error => {
708
- clearTimeout(timer);
720
+ if (settled)
721
+ return;
722
+ settle();
709
723
  const code = error.code;
710
724
  const permissionHint = code === 'EPERM' || code === 'EACCES'
711
725
  ? `; the operating system denied access before '${request.command}' reached the supervisor. `
@@ -718,9 +732,9 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
718
732
  socket.on('data', chunk => {
719
733
  buffer += chunk;
720
734
  const newline = buffer.indexOf('\n');
721
- if (newline < 0)
735
+ if (newline < 0 || settled)
722
736
  return;
723
- clearTimeout(timer);
737
+ settle();
724
738
  try {
725
739
  resolve(JSON.parse(buffer.slice(0, newline)));
726
740
  }
@@ -1,3 +1,4 @@
1
+ import { cloudflaredUnavailableError, cloudflaredVersionError, CLOUDFLARED_VERSION_TIMEOUT_MS, isSecureCloudflaredVersion, isSecureNodeVersion, nodeVersionError } from './security-versions.js';
1
2
  import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs';
2
3
  import { userInfo } from 'node:os';
3
4
  import { join } from 'node:path';
@@ -10,9 +11,8 @@ const UNCHANGED = 'Nothing was changed and this host was not bound.';
10
11
  * proof is sent: a failure afterwards cannot be retried with a fresh command.
11
12
  */
12
13
  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}`);
14
+ if (!isSecureNodeVersion(node))
15
+ throw Error(`${nodeVersionError(node)} ${UNCHANGED}`);
16
16
  if (platform === 'linux') {
17
17
  const manager = await exec('systemctl', ['--user', 'show-environment'], { timeout: 10_000 });
18
18
  if (manager.code !== 0)
@@ -20,6 +20,13 @@ export async function setupTunnelPreflight({ exec = realExec, platform = process
20
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
21
  + `Then run a fresh tunnel setup command. ${UNCHANGED}`);
22
22
  }
23
+ // Refuse an unsafe connector before anything is redeemed; the service checks its own
24
+ // PATH-selected cloudflared again before every tunnel start.
25
+ const version = await exec('cloudflared', ['--version'], { timeout: CLOUDFLARED_VERSION_TIMEOUT_MS });
26
+ if (version.code !== 0)
27
+ throw Error(`${cloudflaredUnavailableError(version.code === 127 ? 'not found' : 'version check failed')} ${UNCHANGED}`);
28
+ if (!isSecureCloudflaredVersion(version.stdout))
29
+ throw Error(`${cloudflaredVersionError(version.stdout)} ${UNCHANGED}`);
23
30
  const help = await exec('cloudflared', ['tunnel', 'run', '--help'], { timeout: 10_000 });
24
31
  if (help.code === 127)
25
32
  throw Error(`cloudflared is not installed or not on PATH. Install it, then run a fresh tunnel setup command. ${UNCHANGED}`);
@@ -131,6 +131,8 @@ export declare class FleetResourceViews {
131
131
  private retire;
132
132
  private emit;
133
133
  private coverage;
134
+ /** Certify each view's last projected source; never a position its capture did not read. */
135
+ private cover;
134
136
  private affects;
135
137
  private schedule;
136
138
  private refresh;
@@ -25,6 +25,8 @@ export const RESOURCE_ASSEMBLY_BYTES = 16 * 1024 * 1024;
25
25
  const CHUNK_BYTES = 256 * 1024;
26
26
  /** Minimum spacing of activity-only follow-up captures of one view. */
27
27
  export const ACTIVITY_CAPTURE_INTERVAL_MS = 1000;
28
+ /** Sparse health: a current view is qualified at most this often without data. */
29
+ const COVERAGE_INTERVAL_MS = 15000;
28
30
  /** Lazily yield byte chunks so backpressure never queues an entire large record.
29
31
  * Base64 prevents splitting a Unicode scalar from corrupting JSON/SSE parsing. */
30
32
  export function* resourceFrames(envelope) {
@@ -263,7 +265,7 @@ export class FleetResourceViews {
263
265
  this.coverage(connection).catch(close);
264
266
  if (!connection.views.size)
265
267
  response.write(': keepalive\n\n');
266
- }, 15000);
268
+ }, COVERAGE_INTERVAL_MS);
267
269
  healthTimer.unref();
268
270
  }
269
271
  return { connectionId: id, close };
@@ -347,7 +349,11 @@ export class FleetResourceViews {
347
349
  // complete batch behind any active chunk assembly and earlier data records.
348
350
  coverage(connection) {
349
351
  const now = Date.now();
350
- const eligible = [...connection.views.values()].filter(view => this.current(connection, view) && !view.failed && !view.running && !view.dirty && view.payload !== undefined && view.covered && view.covered.health === 'healthy' && view.covered.position === view.source.snapshot().position && now - (view.qualifiedAt ?? 0) >= 15000);
352
+ return this.cover(connection, [...connection.views.values()].filter(view => !view.running && !view.dirty && view.covered?.position === view.source.snapshot().position && now - (view.qualifiedAt ?? 0) >= COVERAGE_INTERVAL_MS), now);
353
+ }
354
+ /** Certify each view's last projected source; never a position its capture did not read. */
355
+ cover(connection, candidates, now) {
356
+ const eligible = candidates.filter(view => this.current(connection, view) && !view.failed && view.payload !== undefined && view.covered && view.covered.health === 'healthy');
351
357
  if (!eligible.length)
352
358
  return Promise.resolve();
353
359
  const views = eligible.map(view => ({ protocolVersion: 1, producerGeneration: this.producerGeneration, viewId: view.descriptor.viewId, subscriptionEpoch: view.epoch, baselineId: view.baselineId, sequence: view.sequence + 1, revision: view.revision, source: view.covered }));
@@ -502,6 +508,10 @@ export class FleetResourceViews {
502
508
  return;
503
509
  view.payload = payload;
504
510
  }
511
+ // Sustained activity can keep a view dirty at every health tick while its payload is
512
+ // unchanged; the completed capture still qualifies the source it read.
513
+ else if (Date.now() - (view.qualifiedAt ?? 0) >= COVERAGE_INTERVAL_MS)
514
+ await this.cover(connection, [view], Date.now());
505
515
  if (first) {
506
516
  await this.emit(connection, view, 'ready', { baselineId: view.baselineId, source });
507
517
  if (this.current(connection, view))
@@ -310,7 +310,7 @@ export async function startWebConsole(options) {
310
310
  }
311
311
  let stopTunnel;
312
312
  try {
313
- stopTunnel = startWorkspaceTunnel(browserOrigin);
313
+ stopTunnel = await startWorkspaceTunnel(browserOrigin);
314
314
  }
315
315
  catch (error) {
316
316
  try {
@@ -1 +1,2 @@
1
- export declare function startWorkspaceTunnel(origin: string): () => Promise<void>;
1
+ import { type Exec } from '../exec.js';
2
+ export declare function startWorkspaceTunnel(origin: string, exec?: Exec): Promise<() => Promise<void>>;
@@ -1,8 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { realExec } from '../exec.js';
5
+ import { assertSecureCloudflaredVersion, cloudflaredUnavailableError, CLOUDFLARED_VERSION_TIMEOUT_MS } from '../security-versions.js';
4
6
  import { stateRoot } from '../paths.js';
5
- export function startWorkspaceTunnel(origin) {
7
+ export async function startWorkspaceTunnel(origin, exec = realExec) {
6
8
  const dir = join(stateRoot(), 'workspace'), file = join(dir, 'tunnel.json');
7
9
  if (!existsSync(file))
8
10
  return async () => { };
@@ -15,7 +17,31 @@ export function startWorkspaceTunnel(origin) {
15
17
  const stat = lstatSync(tokenFile);
16
18
  if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())
17
19
  throw Error('Connector credential must be an owned private file');
18
- let stopped = false, child, timer, backoff = 1000;
20
+ // The connector is the executable named cloudflared on this process's PATH, the
21
+ // same lookup spawn uses. Its version is verified before the first start and
22
+ // before every reconnect, so a downgrade is refused and an upgrade is picked up.
23
+ const verify = async () => {
24
+ const result = await exec('cloudflared', ['--version'], { timeout: CLOUDFLARED_VERSION_TIMEOUT_MS });
25
+ if (result.code !== 0)
26
+ throw Error(cloudflaredUnavailableError(result.code === 127 ? 'not found' : 'version check failed'));
27
+ assertSecureCloudflaredVersion(result.stdout);
28
+ };
29
+ await verify();
30
+ let stopped = false, child, timer, backoff = 1000, reported = '';
31
+ const schedule = () => { if (stopped)
32
+ return; timer = setTimeout(reconnect, backoff); backoff = Math.min(30000, backoff * 2); };
33
+ // A refused or unverifiable connector is never started; retry after the backoff.
34
+ const reconnect = () => {
35
+ timer = undefined;
36
+ void verify().then(() => { reported = ''; launch(); }, (error) => {
37
+ if (stopped)
38
+ return;
39
+ if (error.message !== reported)
40
+ console.error(`Workspace tunnel not restarted: ${error.message}`);
41
+ reported = error.message;
42
+ schedule();
43
+ });
44
+ };
19
45
  const launch = () => {
20
46
  if (stopped)
21
47
  return;
@@ -23,7 +49,7 @@ export function startWorkspaceTunnel(origin) {
23
49
  let scheduled = false;
24
50
  // Keep an error listener for the child's whole lifetime, including shutdown.
25
51
  const retry = () => { if (scheduled || stopped)
26
- return; scheduled = true; timer = setTimeout(launch, backoff); backoff = Math.min(30000, backoff * 2); };
52
+ return; scheduled = true; schedule(); };
27
53
  child.on('error', retry);
28
54
  child.once('exit', retry);
29
55
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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",
@@ -30,7 +30,7 @@
30
30
  "docs/task-progress-review.md"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=22.13.0"
33
+ "node": ">=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1 <27"
34
34
  },
35
35
  "scripts": {
36
36
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",