@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 +41 -2
- package/dist/build-info.json +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/doctor.js +17 -4
- package/dist/runtime-security.d.ts +1 -0
- package/dist/runtime-security.js +6 -0
- package/dist/security-versions.d.ts +20 -0
- package/dist/security-versions.js +70 -0
- package/dist/session/control.js +19 -5
- package/dist/setup-tunnel-preflight.js +10 -3
- package/dist/web/resource-views.d.ts +2 -0
- package/dist/web/resource-views.js +12 -2
- package/dist/web/runtime.js +1 -1
- package/dist/web/workspace-tunnel.d.ts +2 -1
- package/dist/web/workspace-tunnel.js +29 -3
- package/package.json +2 -2
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
|
|
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.
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.1.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "2.1.2",
|
|
3
|
+
"buildId": "5bf10a772912",
|
|
4
|
+
"commit": "dd8adad553ca71b0edae5cc1040eacaaed51e50a",
|
|
5
5
|
"dirty": false,
|
|
6
|
-
"builtAt": "2026-10-
|
|
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
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
|
|
121
|
+
const secureNode = isSecureNodeVersion(process.versions.node);
|
|
120
122
|
checks.push({
|
|
121
|
-
name: 'node', ok:
|
|
122
|
-
detail:
|
|
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
|
+
}
|
package/dist/session/control.js
CHANGED
|
@@ -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
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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))
|
package/dist/web/runtime.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
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})\"",
|