feat(worker): support direct Gateway connections (#122683)

* feat(worker): support direct gateway connections

* docs(plan): link node worker provider PR
This commit is contained in:
Peter Steinberger
2026-08-12 09:59:29 -07:00
committed by GitHub
parent 9b80654d48
commit 522f06ffc6
40 changed files with 672 additions and 376 deletions
@@ -1 +1 @@
{"contentHash":"dc2dfe016cb61aa0c79c9647a53eff358ceeb1859859c0d0b6ca752160a879ee","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"81edf86f9ac989d2c85a531a271396c8ca553bd40f80a3e93903b3cf34385146","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"5375849cad6199c5faf3e16a8e67943be3c980b0587a6055d5ad3cd0f3b6d54b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"1bb99b68596361bc2ef9f1c8011f1d87fbbdc78d0bde58d9e89625a5e2bf2de7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"983b9fd128e62f7bb399ba48879ab4817d399d5b28e935cd79468a96833015f5","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"6c638b1fcf5a1cadce5a5363ae4cd8f29fe513feb90331272349ef5bf70bc63a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"bff26d38a6bbe8e5f7c7bfce771c30a8898f445de4e37688dc89870ccd260c8d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"fa4140b50658ecfab11c323c24ba6f0fe0a5cb1608c8fa3e8eefa3e4e4192e77","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"a1a59370087b8f53d5fb24945ac2f0f3b516c7ca0c7d48f6293cd0b223c14f3d","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"5c1b1998403a8527055fff05caa86f51ccd981c08ebd8ba8c7f9da19a8e16e33","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"a1f6948c41d1a539819ae29e15b10a8d0db8a3a4f46dd24d5d0818da449ed727","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"751d37b918948737d4324f6323e1f8f6151f991d55c3794e1908f79225745fc7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"be6416bf69922f215e9a1f76a4fade27af0a74e3214d99b1ca1898b108c60490","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"87d04d89c9a3b50fc63ea3da73e04bf4f6e5ce5738cfe1be11d4023aa680ec78","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"183298d9056641c8b595b8e35427138200bdd09c37d973c7ba5073126761189e","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"2c95ec54b192b730e9a9c606f77e66a2d0dd9eb09dd66a3c6ad8fea9fca31585","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"662e95032c11179b8c3010e0792f23efb583dff086a3289c7351d75e5349d221","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"d2051c433956de7d579b91d4e21e39d48334e63a632baf941bde0cef85ca8ac0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"cfdf45c07d1df4fc251da2be882bbc57228148c88ffb8bd7fc3f6d53951bb89b","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"adf5aa7e44a7bc6f6e0607fa35eae1fcb197749bb5a129c1848f85d61dc319a2","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"1c90dcb9cd3cf2d53eca4a162201584cc7ed3cce67e6315258c2af40381a8ada","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"a9e35fd79c8ba80ca1de7760b0b83a243a383bffbda1730818e5dec2a049ddce","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"7e36c02a156e70484c750a54d8771370ad97a6788d9c69ddd5fe7e768be4507d","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"3ba67320fb902e12fdf2399c4d8e74403e78e74add8dfed8213154f4ab68a858","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"2e7ca18c70c9726f5f26496e015de059badfd83d3c36b43b4dcc640f286ecb74","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"ca3136e446cf1fc294279e24078e3a4c6342a048b00f8012d519903c887ef3a7","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d2d5b75b1cf49439ef71a2b9e46db98fcd6431be792a63a6907e50d9247fe82b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"5f0b7be43fcb5e2240053e7ada316b35f28cbcdd0619b724463627961927dcb9","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"9d621db60ab93bb5b6cbaa6107030f780b4c863420ab7bf01e309f8c356bca5e","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"5c9f0b8207f962f9c32228970abe15c23de32c08a00e3d42cbdf5fecbd4c1f1e","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0898758caccfb3cb789606e81cf692dc919e7f993a4d2f9c4487583d7e05f87a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"018adbdc87ee4a490fdea45b3ee249a55dca337d655cad8929e63986f4e633bb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"740dc824333ee4e1bfdfe410c8c9987914f0699f36e7a19d660e69d9bcd908d7","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"1dac77955687176848405413e59d76ea0511e30722eaa32bf3a3cdd44c5a6395","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+2 -2
View File
@@ -23,8 +23,8 @@ advances a milestone.
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | in progress | #120768, #122499 |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531, #122635 |
| F | Real-wire session boundary harness | landed | #121212 |
| 5 | Public worker ingress path | in progress | #122578, #122643 |
| 6 | Node worker provider (device runners) | not started | — |
| 5 | Public worker ingress path | landed | #122578, #122643 |
| 6 | Node worker provider (device runners) | in progress | #122683 |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
+29 -194
View File
@@ -18,15 +18,11 @@ import {
MIN_PROBE_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "@openclaw/gateway-protocol/version";
import { isLoopbackIpAddress, type ParsedIpAddress } from "@openclaw/net-policy/ip";
import { isWssUrl } from "@openclaw/net-policy/url-protocol";
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
import { WebSocket } from "ws";
import {
isSensitiveUrlQueryParamName,
normalizeFingerprint,
normalizeGatewayErrorText,
parseGatewayIpAddress,
parseHostForAddressChecks,
} from "./client-address-utils.js";
import {
buildGatewayConnectAuth,
@@ -56,6 +52,11 @@ import {
resolveSafeTimeoutDelayMs,
} from "./timeouts.js";
import { rawDataToString } from "./websocket-data.js";
import {
GatewayWebSocketTransportConfigurationError,
isGatewayLoopbackHost,
resolveGatewayWebSocketTransport,
} from "./websocket-transport.js";
export type DeviceIdentity = {
deviceId: string;
@@ -127,91 +128,6 @@ function resolveHostDeps(overrides?: GatewayClientHostDeps): Required<GatewayCli
) as Required<GatewayClientHostDeps>;
}
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<string>([
"loopback",
"private",
"linkLocal",
"carrierGradeNat",
]);
const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<string>([
"loopback",
"linkLocal",
"uniqueLocal",
"deprecatedSiteLocal",
]);
function isPrivateOrLoopbackIpAddress(address: ParsedIpAddress): boolean {
const ranges =
address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES;
return ranges.has(address.range());
}
function isLoopbackHost(host: string): boolean {
const parsed = parseHostForAddressChecks(host);
if (!parsed) {
return false;
}
if (parsed.isLocalhost) {
return true;
}
return isLoopbackIpAddress(parsed.unbracketedHost);
}
function isPrivateOrLoopbackHost(host: string): boolean {
const parsed = parseHostForAddressChecks(host);
if (!parsed) {
return false;
}
if (parsed.isLocalhost) {
return true;
}
const address = parseGatewayIpAddress(parsed.unbracketedHost);
if (!address) {
return false;
}
return isPrivateOrLoopbackIpAddress(address);
}
function isTrustedPlaintextWebSocketHost(hostname: string): boolean {
if (isPrivateOrLoopbackHost(hostname)) {
return true;
}
const normalized = hostname.toLowerCase().trim().replace(/\.+$/, "");
// Plain ws:// is still useful for local discovery and Tailnet names. Public
// hostnames must use wss:// unless the caller opts into the private break-glass.
return normalized.endsWith(".local") || normalized.endsWith(".ts.net");
}
function isSecureWebSocketUrl(rawUrl: string, options?: { allowPrivateWs?: boolean }): boolean {
try {
const url = new URL(rawUrl);
const protocol =
url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol;
if (protocol === "wss:") {
return true;
}
if (protocol !== "ws:") {
return false;
}
if (isLoopbackHost(url.hostname) || isTrustedPlaintextWebSocketHost(url.hostname)) {
return true;
}
if (options?.allowPrivateWs === true) {
const hostForIpCheck =
url.hostname.startsWith("[") && url.hostname.endsWith("]")
? url.hostname.slice(1, -1)
: url.hostname;
return (
isPrivateOrLoopbackHost(url.hostname) || parseGatewayIpAddress(hostForIpCheck) === undefined
);
}
return false;
} catch {
return false;
}
}
export type GatewayClientRequestOptions = GatewayProtocolRequestOptions;
type AssembledConnect = {
@@ -224,10 +140,6 @@ type AssembledConnect = {
usingStoredDeviceToken: boolean | undefined;
};
type FingerprintCheckingClientOptions = Omit<ClientOptions, "checkServerIdentity"> & {
checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined;
};
const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789";
const DEFAULT_CLIENT_VERSION = "0.0.0";
const MAX_UPGRADE_ERROR_BODY_BYTES = 2 * 1024;
@@ -299,9 +211,7 @@ export class GatewayClientRequestTimeoutError extends GatewayProtocolRequestTime
}
}
class GatewayClientSocketFactoryConfigurationError extends Error {}
class GatewayClientTransportPolicyError extends GatewayClientSocketFactoryConfigurationError {}
class GatewayClientTransportPolicyError extends GatewayWebSocketTransportConfigurationError {}
const GATEWAY_CONNECT_ASSEMBLY_ERROR = Symbol("gateway.connectAssemblyError");
@@ -531,7 +441,7 @@ export class GatewayClient {
reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 },
requestTimeoutMs: this.requestTimeoutMs,
shouldRetrySocketFactoryError: (error) =>
!(error instanceof GatewayClientSocketFactoryConfigurationError) &&
!(error instanceof GatewayWebSocketTransportConfigurationError) &&
!(error instanceof SyntaxError) &&
!(error instanceof TypeError) &&
!(error instanceof RangeError),
@@ -570,72 +480,26 @@ export class GatewayClient {
private createSocket(handlers: GatewayProtocolSocketHandlers): GatewayProtocolSocket {
const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL;
const usesTls = isWssUrl(url);
if (this.opts.tlsFingerprint && !usesTls) {
throw new GatewayClientSocketFactoryConfigurationError(
"gateway tls fingerprint requires wss:// gateway url",
);
}
const allowPrivateWs =
(this.opts.env ?? process.env).OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
// Block plaintext before device-token lookup. Credentials may be loaded from
// host storage later in sendConnect(), and chat payloads are sensitive too.
if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
// Safe hostname extraction - avoid throwing on malformed URLs in error path
let displayHost = url;
try {
displayHost = new URL(url).hostname || url;
} catch {
// Use raw URL if parsing fails
}
throw new GatewayClientSocketFactoryConfigurationError(
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
"Both credentials and chat data would be exposed to network interception. " +
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
(allowPrivateWs
? ""
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
"Run `openclaw doctor --fix` for guidance.",
);
}
// Allow node screen snapshots and other large responses.
this.deps.beforeConnect();
// Challenge timeout arms only after `open`. Bound the opening handshake so a
// peer that accepts TCP without upgrading cannot hang createSocket forever.
const handshakeTimeoutMs = resolvePreauthHandshakeTimeoutMs({
env: this.opts.env,
configuredTimeoutMs: this.opts.preauthHandshakeTimeoutMs,
});
const wsOptions: FingerprintCheckingClientOptions = {
maxPayload: 25 * 1024 * 1024,
handshakeTimeout: handshakeTimeoutMs,
...(this.opts.origin ? { origin: this.opts.origin } : {}),
};
if (usesTls && this.opts.tlsFingerprint) {
wsOptions.rejectUnauthorized = false;
wsOptions.checkServerIdentity = (_hostValue: string, cert: CertMeta) => {
const fingerprintValue =
typeof cert === "object" && cert && "fingerprint256" in cert
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
: "";
const fingerprint = this.deps.normalizeTlsFingerprint(
typeof fingerprintValue === "string" ? fingerprintValue : "",
);
const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint ?? "");
if (!expected) {
return undefined;
}
if (!fingerprint) {
return new Error("Missing server TLS fingerprint");
}
if (fingerprint !== expected) {
return new Error("Server TLS fingerprint mismatch");
}
return undefined;
};
}
const transport = resolveGatewayWebSocketTransport({
url,
tlsFingerprint: this.opts.tlsFingerprint,
env: this.opts.env,
normalizeTlsFingerprint: this.deps.normalizeTlsFingerprint,
options: {
// Allow node screen snapshots and other large responses. The challenge
// timer starts after open, so separately bound the HTTP upgrade here.
maxPayload: 25 * 1024 * 1024,
handshakeTimeout: handshakeTimeoutMs,
...(this.opts.origin ? { origin: this.opts.origin } : {}),
},
});
this.deps.beforeConnect();
let ws: WebSocket;
// Managed proxies can intercept local traffic; the host owns the bypass
// lifecycle and must remove it immediately after the socket is created.
@@ -648,7 +512,7 @@ export class GatewayClient {
);
}
try {
ws = new WebSocket(url, wsOptions as ClientOptions);
ws = new WebSocket(url, transport.options);
ws.binaryType = "nodebuffer";
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
@@ -660,13 +524,11 @@ export class GatewayClient {
let upgradeError: GatewayClientRequestError | undefined;
ws.on("open", () => {
handlers.open();
if (usesTls && this.opts.tlsFingerprint) {
const tlsError = this.validateTlsFingerprint();
if (tlsError) {
handlers.error(tlsError);
ws.close(1008, tlsError.message);
return;
}
const tlsError = transport.validateSocket(ws);
if (tlsError) {
handlers.error(tlsError);
ws.close(1008, tlsError.message);
return;
}
this.transportValidated = true;
});
@@ -1327,7 +1189,7 @@ export class GatewayClient {
: parsed.protocol === "http:"
? "ws:"
: parsed.protocol;
if (isLoopbackHost(parsed.hostname)) {
if (isGatewayLoopbackHost(parsed.hostname)) {
return true;
}
return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim());
@@ -1399,33 +1261,6 @@ export class GatewayClient {
}, interval);
}
private validateTlsFingerprint(): Error | null {
if (!this.opts.tlsFingerprint || !this.ws) {
return null;
}
const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint);
if (!expected) {
return new Error("gateway tls fingerprint missing");
}
const socket = (
this.ws as WebSocket & {
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
}
)["_socket"];
if (!socket || typeof socket.getPeerCertificate !== "function") {
return new Error("gateway tls fingerprint unavailable");
}
const cert = socket.getPeerCertificate();
const fingerprint = this.deps.normalizeTlsFingerprint(cert?.fingerprint256 ?? "");
if (!fingerprint) {
return new Error("gateway tls fingerprint unavailable");
}
if (fingerprint !== expected) {
return new Error("gateway tls fingerprint mismatch");
}
return null;
}
async request<T = Record<string, unknown>>(
method: string,
params?: unknown,
@@ -0,0 +1,177 @@
import { isLoopbackIpAddress, type ParsedIpAddress } from "@openclaw/net-policy/ip";
import { isWssUrl } from "@openclaw/net-policy/url-protocol";
import type { ClientOptions, CertMeta, WebSocket } from "ws";
import {
normalizeFingerprint,
parseGatewayIpAddress,
parseHostForAddressChecks,
} from "./client-address-utils.js";
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<string>([
"loopback",
"private",
"linkLocal",
"carrierGradeNat",
]);
const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<string>([
"loopback",
"linkLocal",
"uniqueLocal",
"deprecatedSiteLocal",
]);
function isPrivateOrLoopbackIpAddress(address: ParsedIpAddress): boolean {
const ranges =
address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES;
return ranges.has(address.range());
}
export function isGatewayLoopbackHost(host: string): boolean {
const parsed = parseHostForAddressChecks(host);
return Boolean(parsed && (parsed.isLocalhost || isLoopbackIpAddress(parsed.unbracketedHost)));
}
function isPrivateOrLoopbackHost(host: string): boolean {
const parsed = parseHostForAddressChecks(host);
if (!parsed) {
return false;
}
if (parsed.isLocalhost) {
return true;
}
const address = parseGatewayIpAddress(parsed.unbracketedHost);
return Boolean(address && isPrivateOrLoopbackIpAddress(address));
}
function isTrustedPlaintextWebSocketHost(hostname: string): boolean {
if (isPrivateOrLoopbackHost(hostname)) {
return true;
}
const normalized = hostname.toLowerCase().trim().replace(/\.+$/, "");
return normalized.endsWith(".local") || normalized.endsWith(".ts.net");
}
function isSecureWebSocketUrl(rawUrl: string, options?: { allowPrivateWs?: boolean }): boolean {
try {
const url = new URL(rawUrl);
const protocol =
url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol;
if (protocol === "wss:") {
return true;
}
if (protocol !== "ws:") {
return false;
}
if (isGatewayLoopbackHost(url.hostname) || isTrustedPlaintextWebSocketHost(url.hostname)) {
return true;
}
if (options?.allowPrivateWs === true) {
const hostForIpCheck =
url.hostname.startsWith("[") && url.hostname.endsWith("]")
? url.hostname.slice(1, -1)
: url.hostname;
return (
isPrivateOrLoopbackHost(url.hostname) || parseGatewayIpAddress(hostForIpCheck) === undefined
);
}
return false;
} catch {
return false;
}
}
export class GatewayWebSocketTransportConfigurationError extends Error {}
type FingerprintCheckingClientOptions = Omit<ClientOptions, "checkServerIdentity"> & {
checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined;
};
type GatewayWebSocketTransport = {
options: ClientOptions;
validateSocket(socket: WebSocket): Error | null;
};
export function resolveGatewayWebSocketTransport(params: {
url: string;
tlsFingerprint?: string;
env?: NodeJS.ProcessEnv;
options: Omit<ClientOptions, "checkServerIdentity" | "rejectUnauthorized">;
normalizeTlsFingerprint?: (fingerprint: string | undefined) => string;
}): GatewayWebSocketTransport {
const usesTls = isWssUrl(params.url);
if (params.tlsFingerprint && !usesTls) {
throw new GatewayWebSocketTransportConfigurationError(
"gateway tls fingerprint requires wss:// gateway url",
);
}
const allowPrivateWs = (params.env ?? process.env).OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
if (!isSecureWebSocketUrl(params.url, { allowPrivateWs })) {
let displayHost = params.url;
try {
displayHost = new URL(params.url).hostname || params.url;
} catch {
// Use the raw URL when syntax is malformed.
}
throw new GatewayWebSocketTransportConfigurationError(
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
"Both credentials and chat data would be exposed to network interception. " +
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
(allowPrivateWs
? ""
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
"Run `openclaw doctor --fix` for guidance.",
);
}
const normalize = params.normalizeTlsFingerprint ?? normalizeFingerprint;
const options: FingerprintCheckingClientOptions = { ...params.options };
if (usesTls && params.tlsFingerprint) {
options.rejectUnauthorized = false;
options.checkServerIdentity = (_hostValue: string, cert: CertMeta) => {
const fingerprintValue =
typeof cert === "object" && cert && "fingerprint256" in cert
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
: "";
const fingerprint = normalize(typeof fingerprintValue === "string" ? fingerprintValue : "");
const expected = normalize(params.tlsFingerprint);
if (!expected) {
return undefined;
}
if (!fingerprint) {
return new Error("Missing server TLS fingerprint");
}
if (fingerprint !== expected) {
return new Error("Server TLS fingerprint mismatch");
}
return undefined;
};
}
return {
options: options as ClientOptions,
validateSocket: (socket) => {
if (!params.tlsFingerprint) {
return null;
}
const expected = normalize(params.tlsFingerprint);
if (!expected) {
return new Error("gateway tls fingerprint missing");
}
const rawSocket = (
socket as WebSocket & {
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
}
)["_socket"];
if (!rawSocket || typeof rawSocket.getPeerCertificate !== "function") {
return new Error("gateway tls fingerprint unavailable");
}
const cert = rawSocket.getPeerCertificate();
const fingerprint = normalize(cert?.fingerprint256 ?? "");
if (!fingerprint) {
return new Error("gateway tls fingerprint unavailable");
}
return fingerprint === expected ? null : new Error("gateway tls fingerprint mismatch");
},
};
}
+39 -53
View File
@@ -2,7 +2,6 @@
* Gateway pre-auth hardening tests.
*/
import http from "node:http";
import { rawDataToString } from "@openclaw/gateway-client/websocket-data";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import {
@@ -22,6 +21,7 @@ import {
tryBeginGatewaySuspendAdmission,
} from "../process/gateway-work-admission.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { createWorkerConnection } from "../worker/worker-connection.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
import {
@@ -232,7 +232,7 @@ describe("gateway pre-auth hardening", () => {
}
});
it("admits a valid worker over the public path without a gateway challenge", async () => {
it("admits the production worker client over the public path without a gateway challenge", async () => {
const clients = new Set<GatewayWsClient>();
const resolvedAuth: ResolvedGatewayAuth = { mode: "none", allowTailscale: false };
const httpServer = createGatewayHttpServer({
@@ -303,62 +303,48 @@ describe("gateway pre-auth hardening", () => {
});
const address = httpServer.address();
const port = typeof address === "object" && address ? address.port : 0;
const client = new WebSocket(`ws://127.0.0.1:${port}${WORKER_PUBLIC_INGRESS_PATH}`);
const received: unknown[] = [];
client.on("message", (data) => received.push(JSON.parse(rawDataToString(data))));
const client = createWorkerConnection({
endpoint: {
kind: "websocket",
url: `ws://127.0.0.1:${port}${WORKER_PUBLIC_INGRESS_PATH}`,
},
connectParams: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: GATEWAY_CLIENT_IDS.WORKER,
version: "2026.8.12",
platform: "linux",
mode: GATEWAY_CLIENT_MODES.WORKER,
},
role: "worker",
admission: {
environmentId: "worker-public",
credential: "public-worker-credential",
sessionId: null,
runId: null,
ownerEpoch: 1,
rpcSetVersion: 1,
handshake: {
bundleHash: "a".repeat(64),
openclawVersion: "2026.8.12",
protocolFeatures: [],
},
},
},
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
admissionTimeoutMs: 2_000,
});
try {
await new Promise<void>((resolve, reject) => {
client.once("open", resolve);
client.once("error", reject);
await client.start();
expect(client.state).toMatchObject({
kind: "ready",
hello: { type: "worker-hello-ok", environmentId: "worker-public" },
});
client.send(
JSON.stringify({
type: "req",
id: "connect-public-worker",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: GATEWAY_CLIENT_IDS.WORKER,
version: "2026.8.12",
platform: "linux",
mode: GATEWAY_CLIENT_MODES.WORKER,
},
role: "worker",
admission: {
environmentId: "worker-public",
credential: "public-worker-credential",
sessionId: null,
runId: null,
ownerEpoch: 1,
rpcSetVersion: 1,
handshake: {
bundleHash: "a".repeat(64),
openclawVersion: "2026.8.12",
protocolFeatures: [],
},
},
},
}),
);
await vi.waitFor(() => expect(received).toHaveLength(1));
expect(received[0]).toMatchObject({
type: "res",
id: "connect-public-worker",
ok: true,
payload: { type: "worker-hello-ok", environmentId: "worker-public" },
});
expect(received).not.toContainEqual(
expect.objectContaining({ type: "event", event: "connect.challenge" }),
);
expect(workerConnectionService.admitWorker).toHaveBeenCalledOnce();
} finally {
client.close();
await new Promise<void>((resolve) => {
client.once("close", () => resolve());
});
await client.stop();
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
@@ -23,7 +23,8 @@ describe("worker environment service", () => {
return {
environmentId: request.environmentId,
ownerEpoch: request.ownerEpoch,
remoteSocketPath: "/tmp/worker/gateway.sock",
connectionEndpoint: { kind: "unix", socketPath: "/tmp/worker/gateway.sock" },
launchTurn: vi.fn(),
runWorkspaceCommand: vi.fn(),
syncWorkspace: vi.fn(),
stop: async () => {},
@@ -86,7 +87,8 @@ describe("worker environment service", () => {
start: vi.fn(async (request: Parameters<WorkerTunnelManager["start"]>[0]) => ({
environmentId: request.environmentId,
ownerEpoch: request.ownerEpoch,
remoteSocketPath: "/tmp/worker/gateway.sock",
connectionEndpoint: { kind: "unix", socketPath: "/tmp/worker/gateway.sock" },
launchTurn: vi.fn(),
runWorkspaceCommand: vi.fn(),
syncWorkspace: vi.fn(),
stop: async () => {},
@@ -175,7 +175,8 @@ export function createHarness(
const tunnelHandle = (ownerEpoch: number): WorkerTunnelHandle => ({
environmentId: ready.environmentId,
ownerEpoch,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix", socketPath: "/worker/gateway.sock" },
launchTurn: vi.fn(),
quiesceWorkspace: vi.fn(async () => {
log.push("workspace:quiesce");
return {
@@ -1,4 +1,6 @@
import type { SpawnResult } from "../../process/exec.js";
import type { WorkerLaunchDescriptor } from "../../worker/launch-descriptor.js";
import type { WorkerConnectionEndpoint } from "../../worker/worker-connection-endpoint.js";
import type {
WorkerWorkspaceApplyResult,
WorkerWorkspaceReconciliationJournalAdapter,
@@ -72,10 +74,17 @@ export type WorkerWorkspaceQuiescence = {
resume(): Promise<void>;
};
type WorkerTurnLaunchRequest = {
descriptor: WorkerLaunchDescriptor;
timeoutMs?: number;
signal?: AbortSignal;
};
export type WorkerTunnelHandle = {
environmentId: string;
ownerEpoch: number;
remoteSocketPath: string;
connectionEndpoint: WorkerConnectionEndpoint;
launchTurn(request: WorkerTurnLaunchRequest): Promise<SpawnResult>;
runWorkspaceCommand(command: WorkerWorkspaceCommand): Promise<SpawnResult>;
quiesceWorkspace(remoteWorkspaceDir: string): Promise<WorkerWorkspaceQuiescence>;
syncWorkspace(request: WorkerWorkspaceSyncRequest): Promise<WorkerWorkspaceSyncResult>;
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkerLaunchDescriptor } from "../../worker/launch-descriptor.js";
import { createWorkerSshRunner } from "./tunnel-ssh-runner.js";
import { createWorkerTunnelManager } from "./tunnel.js";
import {
@@ -68,6 +69,21 @@ describe("worker tunnel manager", () => {
expect(workspace?.argv).toContain("ControlPath=none");
expect(workspace?.argv.at(-1)).toContain("pwd");
expect(fake.starts).toHaveLength(1);
expect(handle.connectionEndpoint).toMatchObject({
kind: "unix",
socketPath: expect.stringMatching(/\/gateway\.sock$/u),
});
const descriptor = { version: 3 } as unknown as WorkerLaunchDescriptor;
await expect(handle.launchTurn({ descriptor, timeoutMs: 123 })).resolves.toEqual(success());
const launch = fake.runs.at(-1);
const remoteLaunchCommand = launch?.argv.at(-1) ?? "";
expect(remoteLaunchCommand).toContain("'sh' '-c'");
expect(remoteLaunchCommand).toContain(
'exec node "$HOME/.openclaw-worker/$1/openclaw.mjs" worker',
);
expect(remoteLaunchCommand).toContain(`'${BUNDLE_HASH}'`);
expect(launch?.options.input).toBe(JSON.stringify(descriptor));
expect(launch?.options.timeoutMs).toBe(123);
await handle.stop();
expect(tunnel?.process.stopCount).toBe(1);
expect(manager.status("worker:one")).toBe("stopped");
+20 -8
View File
@@ -78,6 +78,7 @@ directory=$2
rm -f -- "$socket"
rmdir -- "$directory" 2>/dev/null || true
`;
const WORKER_LAUNCH_SCRIPT = 'exec node "$HOME/.openclaw-worker/$1/openclaw.mjs" worker';
type WorkerTunnelStartRequest = WorkerTunnelRequest & {
bundleHash: string;
@@ -230,11 +231,8 @@ export function createWorkerTunnelManager(options: WorkerTunnelManagerOptions =
).catch(() => undefined);
};
const createHandle = (entry: TunnelEntry): WorkerTunnelHandle => ({
environmentId: entry.environmentId,
ownerEpoch: entry.ownerEpoch,
remoteSocketPath: entry.remoteSocketPath,
...createWorkerWorkspaceActions({
const createHandle = (entry: TunnelEntry): WorkerTunnelHandle => {
const workspace = createWorkerWorkspaceActions({
environmentId: entry.environmentId,
sharedHost: entry.sharedHost,
ownerSignal: entry.abortController.signal,
@@ -243,9 +241,23 @@ export function createWorkerTunnelManager(options: WorkerTunnelManagerOptions =
runner,
tasks: entry.workspaceTasks,
bundleHash: entry.bundleHash,
}),
stop: () => stop(entry.environmentId, entry.ownerEpoch),
});
});
return {
environmentId: entry.environmentId,
ownerEpoch: entry.ownerEpoch,
connectionEndpoint: { kind: "unix", socketPath: entry.remoteSocketPath },
launchTurn: (request) =>
workspace.runWorkspaceCommand({
transportRetry: "never",
argv: ["sh", "-c", WORKER_LAUNCH_SCRIPT, "openclaw-worker", entry.bundleHash],
input: JSON.stringify(request.descriptor),
timeoutMs: request.timeoutMs,
signal: request.signal,
}),
...workspace,
stop: () => stop(entry.environmentId, entry.ownerEpoch),
};
};
const connect = async (
entry: TunnelEntry,
@@ -743,7 +743,7 @@ describe("worker turn launcher", () => {
const tunnel: WorkerTunnelHandle = {
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {
@@ -754,14 +754,15 @@ describe("worker turn launcher", () => {
expect(placements.listPendingWorkspaceResults()).toHaveLength(1);
}),
})),
runWorkspaceCommand: vi.fn(async (command): Promise<SpawnResult> => {
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(async (request): Promise<SpawnResult> => {
expect(placements.get(SESSION_ID)?.turnClaim).toMatchObject({
owner: "worker",
runId: "run-worker-turn",
ownerEpoch: OWNER_EPOCH,
});
descriptor = parseWorkerLaunchDescriptor(JSON.parse(command.input ?? ""));
expect(command.transportRetry).toBe("never");
descriptor = parseWorkerLaunchDescriptor(structuredClone(request.descriptor));
expect(request.timeoutMs).toBe(5_000);
const activeRuntimeIdentity = await verifyAgentRuntimeIdentityToken(
descriptor.assignment.agentRuntimeIdentityToken,
);
@@ -770,14 +771,10 @@ describe("worker turn launcher", () => {
activeRuntimeIdentity &&
createAgentRuntimeApprovalAuthorityValidator(placements)(activeRuntimeIdentity),
).toBe(true);
expect(command.argv).toEqual([
"sh",
"-c",
'exec node "$HOME/.openclaw-worker/$1/openclaw.mjs" worker',
"openclaw-worker",
BUNDLE_HASH,
]);
expect(command.argv.join(" ")).not.toContain(credential().credential);
expect(descriptor.connectionEndpoint).toEqual({
kind: "unix",
socketPath: "/worker/gateway.sock",
});
await Promise.resolve();
expect(acknowledgeCredentialDelivery).toHaveBeenCalledOnce();
const completed = openSessionManager();
@@ -885,7 +882,7 @@ describe("worker turn launcher", () => {
expect(descriptor?.assignment.prompt).toBe("Inspect this workspace");
expect(descriptor?.assignment.suppressPromptTranscript).toBe(true);
expect(descriptor?.assignment.agentId).toBe(sessionTarget.agentId);
expect(descriptor?.version).toBe(2);
expect(descriptor?.version).toBe(3);
const verifiedRuntimeIdentity = await verifyAgentRuntimeIdentityToken(
descriptor?.assignment.agentRuntimeIdentityToken,
);
@@ -1007,13 +1004,14 @@ describe("worker turn launcher", () => {
const tunnel: WorkerTunnelHandle = {
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand: vi.fn(async (command): Promise<SpawnResult> => {
descriptor = parseWorkerLaunchDescriptor(JSON.parse(command.input ?? ""));
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(async (request): Promise<SpawnResult> => {
descriptor = parseWorkerLaunchDescriptor(structuredClone(request.descriptor));
const completed = openSessionManager();
const leafId = completed.appendMessage(
makeAgentAssistantMessage({
@@ -1142,12 +1140,13 @@ describe("worker turn launcher", () => {
const tunnel: WorkerTunnelHandle = {
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand: vi.fn(async (): Promise<SpawnResult> => {
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(async (): Promise<SpawnResult> => {
const completed = openSessionManager();
const leafId = completed.appendMessage(
makeAgentAssistantMessage({
@@ -1227,12 +1226,13 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand: vi.fn(async (): Promise<SpawnResult> => {
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(async (): Promise<SpawnResult> => {
const completed = openSessionManager();
completed.appendMessage(
makeAgentAssistantMessage({
@@ -1426,7 +1426,7 @@ describe("worker turn launcher", () => {
isError: false,
timestamp: 2,
});
const runWorkspaceCommand = vi.fn(async (): Promise<SpawnResult> => {
const launchTurn = vi.fn(async (): Promise<SpawnResult> => {
throw new Error("unexpected worker handoff");
});
const acknowledgeCredentialDelivery = vi.fn(() => true);
@@ -1434,9 +1434,10 @@ describe("worker turn launcher", () => {
async (): Promise<WorkerTunnelHandle> => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(),
runWorkspaceCommand,
runWorkspaceCommand: vi.fn(),
launchTurn,
syncWorkspace: vi.fn(),
reconcileWorkspace: vi.fn(),
stop: vi.fn(async () => {}),
@@ -1469,7 +1470,7 @@ describe("worker turn launcher", () => {
).rejects.toThrow(WORKER_PROVIDER_REPLAY_LOCAL_RETRY_MESSAGE);
expect(startTunnel).toHaveBeenCalledOnce();
expect(runWorkspaceCommand).not.toHaveBeenCalled();
expect(launchTurn).not.toHaveBeenCalled();
expect(runLocal).not.toHaveBeenCalled();
expect(acknowledgeCredentialDelivery).not.toHaveBeenCalled();
expect(stopTunnel).not.toHaveBeenCalled();
@@ -1480,7 +1481,7 @@ describe("worker turn launcher", () => {
it("preserves a terminal workspace result when the worker child later exits nonzero", async () => {
seedActivePlacement();
const destroy = vi.fn(async () => attachedEnvironment());
const runWorkspaceCommand = vi.fn(async (): Promise<SpawnResult> => {
const launchTurn = vi.fn(async (): Promise<SpawnResult> => {
createWorkerSessionPlacementGate(placements).updateAckCursors({
sessionId: SESSION_ID,
environmentId: ENVIRONMENT_ID,
@@ -1504,9 +1505,10 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(),
runWorkspaceCommand,
runWorkspaceCommand: vi.fn(),
launchTurn,
syncWorkspace: vi.fn(),
reconcileWorkspace: vi.fn(),
stop: vi.fn(async () => {}),
@@ -1529,7 +1531,7 @@ describe("worker turn launcher", () => {
),
).rejects.toThrow("child cleanup failed");
expect(runWorkspaceCommand).toHaveBeenCalledOnce();
expect(launchTurn).toHaveBeenCalledOnce();
expect(destroy).not.toHaveBeenCalled();
expect(placements.listPendingWorkspaceResults()).toMatchObject([
{
@@ -1656,12 +1658,13 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand: vi.fn(async () => {
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(async () => {
throw new Error("remote launch failed");
}),
syncWorkspace: vi.fn(async () => {
@@ -1732,8 +1735,9 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
runWorkspaceCommand: vi.fn(
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(
async (): Promise<SpawnResult> => ({
stdout: "",
stderr,
@@ -1944,7 +1948,7 @@ describe("worker turn launcher", () => {
killed: false;
termination: "exit";
}>();
const runWorkspaceCommand = vi.fn(() => {
const launchTurn = vi.fn(() => {
commandStarted.resolve();
return commandFinished.promise;
});
@@ -1955,12 +1959,13 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand,
runWorkspaceCommand: vi.fn(),
launchTurn,
syncWorkspace: vi.fn(async () => {
throw new Error("unexpected workspace sync");
}),
@@ -1995,7 +2000,7 @@ describe("worker turn launcher", () => {
meta: { durationMs: 1 },
})),
).rejects.toThrow("already has an active turn claim");
expect(runWorkspaceCommand).toHaveBeenCalledOnce();
expect(launchTurn).toHaveBeenCalledOnce();
const completed = openSessionManager();
const leafId = completed.appendMessage(
@@ -2063,14 +2068,15 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand: vi.fn(async (command): Promise<SpawnResult> => {
runWorkspaceCommand: vi.fn(),
launchTurn: vi.fn(async (request): Promise<SpawnResult> => {
launchCount += 1;
const descriptor = parseWorkerLaunchDescriptor(JSON.parse(command.input ?? ""));
const descriptor = parseWorkerLaunchDescriptor(structuredClone(request.descriptor));
turnIds.push(descriptor.assignment.turnId);
if (launchCount === 1) {
const completed = openSessionManager();
@@ -2219,7 +2225,7 @@ describe("worker turn launcher", () => {
}
return active;
};
const runWorkspaceCommand = vi.fn(async (): Promise<SpawnResult> => {
const launchTurn = vi.fn(async (): Promise<SpawnResult> => {
workerStarted.resolve();
await resumeWorker.promise;
expect(placements.get(SESSION_ID)).toMatchObject({
@@ -2261,12 +2267,13 @@ describe("worker turn launcher", () => {
startTunnel: vi.fn(async () => ({
environmentId: ENVIRONMENT_ID,
ownerEpoch: OWNER_EPOCH,
remoteSocketPath: "/worker/gateway.sock",
connectionEndpoint: { kind: "unix" as const, socketPath: "/worker/gateway.sock" },
quiesceWorkspace: vi.fn(async () => ({
assertActive: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
})),
runWorkspaceCommand,
runWorkspaceCommand: vi.fn(),
launchTurn,
syncWorkspace: vi.fn(async () => {
throw new Error("unexpected workspace sync");
}),
@@ -2349,7 +2356,7 @@ describe("worker turn launcher", () => {
);
expect(result.payloads).toEqual([{ text: "Redispatched worker reply" }]);
expect(redispatchCalls).toBe(1);
expect(runWorkspaceCommand).toHaveBeenCalledOnce();
expect(launchTurn).toHaveBeenCalledOnce();
expect(runLocal).not.toHaveBeenCalled();
expect(placements.get(SESSION_ID)).toMatchObject({ state: "active", turnClaim: null });
});
@@ -57,8 +57,6 @@ import {
} from "./workspace-result-finalize.js";
import { workerWorkspaceResultRef } from "./workspace-result-staging.js";
const WORKER_LAUNCH_SCRIPT = 'exec node "$HOME/.openclaw-worker/$1/openclaw.mjs" worker';
type WorkerTurnEnvironmentService = Pick<
WorkerEnvironmentService,
| "acknowledgeCredentialDelivery"
@@ -326,8 +324,8 @@ async function executeWorkerTurn(params: {
messages: initialMessages,
build: (agentRuntimeIdentityToken, windowedMessages) =>
parseWorkerLaunchDescriptor({
version: 2,
socketPath: tunnel.remoteSocketPath,
version: 3,
connectionEndpoint: tunnel.connectionEndpoint,
admission: {
environmentId: placement.environmentId,
credential: credential.credential,
@@ -375,10 +373,8 @@ async function executeWorkerTurn(params: {
turn.onExecutionPhase?.({ phase: "attempt_dispatch", backend: "cloud-worker" });
const handoffAbort = new AbortController();
params.onHandoff();
const processPromise = tunnel.runWorkspaceCommand({
transportRetry: "never",
argv: ["sh", "-c", WORKER_LAUNCH_SCRIPT, "openclaw-worker", placement.workerBundleHash],
input: JSON.stringify(descriptor),
const processPromise = tunnel.launchTurn({
descriptor,
timeoutMs: turn.timeoutMs,
signal: turn.abortSignal
? AbortSignal.any([turn.abortSignal, handoffAbort.signal])
@@ -82,8 +82,8 @@ function buildDescriptor(
operationalRunInstance: OperationalRunInstanceRef,
): WorkerLaunchDescriptor {
return {
version: 2,
socketPath: "/tmp/worker.sock",
version: 3,
connectionEndpoint: { kind: "unix", socketPath: "/tmp/worker.sock" },
admission: {
environmentId: "environment",
credential: "worker-fixture-credential",
+42 -4
View File
@@ -11,8 +11,8 @@ import { buildWorkerConnectParams, parseWorkerLaunchDescriptor } from "./launch-
function launchDescriptor(): WorkerLaunchDescriptor {
return {
version: 2,
socketPath: "/tmp/openclaw-worker/gateway.sock",
version: 3,
connectionEndpoint: { kind: "unix", socketPath: "/tmp/openclaw-worker/gateway.sock" },
admission: {
environmentId: "environment-1",
credential: ["worker", "fixture", "value"].join("-"),
@@ -62,6 +62,41 @@ describe("worker launch descriptor", () => {
});
});
it("accepts only closed Unix or public WebSocket connection endpoints", () => {
const descriptor = launchDescriptor();
descriptor.connectionEndpoint = {
kind: "websocket",
url: "wss://gateway.example/tenant/__openclaw__/worker",
tlsFingerprint: "ab:".repeat(31) + "ab",
};
expect(parseWorkerLaunchDescriptor(structuredClone(descriptor))).toEqual(descriptor);
const invalidEndpoints: unknown[] = [
{ kind: "unix", socketPath: "gateway.sock" },
{ kind: "unix", socketPath: "/tmp/gateway:sock" },
{ kind: "websocket", url: "https://gateway.example/__openclaw__/worker" },
{ kind: "websocket", url: "ws://user@gateway.example/__openclaw__/worker" },
{ kind: "websocket", url: "wss://gateway.example/other" },
{ kind: "websocket", url: "wss://gateway.example/__openclaw__/worker?token=x" },
{
kind: "websocket",
url: "ws://127.0.0.1/__openclaw__/worker",
tlsFingerprint: "ab".repeat(32),
},
{
kind: "websocket",
url: "wss://gateway.example/__openclaw__/worker",
tlsFingerprint: "",
},
{ ...descriptor.connectionEndpoint, unexpected: true },
];
for (const connectionEndpoint of invalidEndpoints) {
expect(() => parseWorkerLaunchDescriptor({ ...descriptor, connectionEndpoint })).toThrow(
"invalid worker launch descriptor",
);
}
});
it("rejects unknown fields at every launch-owned boundary", () => {
const descriptor = launchDescriptor();
const cases: unknown[] = [
@@ -129,7 +164,7 @@ describe("worker launch descriptor", () => {
const descriptor = launchDescriptor();
const { toolAuthority: _missing, ...assignmentWithoutAuthority } = descriptor.assignment;
const cases: unknown[] = [
{ ...descriptor, version: 1 },
{ ...descriptor, version: 2 },
{ ...descriptor, assignment: assignmentWithoutAuthority },
{
...descriptor,
@@ -220,7 +255,10 @@ describe("worker launch descriptor", () => {
it("rejects non-absolute paths, unattached sessions, and discontinuous event sequences", () => {
const descriptor = launchDescriptor();
const cases: unknown[] = [
{ ...descriptor, socketPath: "gateway.sock" },
{
...descriptor,
connectionEndpoint: { kind: "unix", socketPath: "gateway.sock" },
},
{
...descriptor,
assignment: { ...descriptor.assignment, workspaceDir: "workspace" },
+12 -9
View File
@@ -27,8 +27,12 @@ import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js
import type { OperationalRunInstanceRef } from "../agents/admitted-run-context.js";
import { isWorkerToolName, type WorkerToolAuthority } from "./tool-authority.js";
import { isWorkerTranscriptMessageFrameSafe } from "./transcript-message.js";
import {
parseWorkerConnectionEndpoint,
type WorkerConnectionEndpoint,
} from "./worker-connection-endpoint.js";
const LAUNCH_VERSION = 2;
const LAUNCH_VERSION = 3;
export type WorkerBrowserLaunchDescriptor = {
cdpUrl: string;
@@ -67,8 +71,8 @@ type WorkerLaunchAdmission = Omit<WorkerConnectParams["admission"], "runId"> & {
};
export type WorkerLaunchDescriptor = {
version: 2;
socketPath: string;
version: 3;
connectionEndpoint: WorkerConnectionEndpoint;
admission: WorkerLaunchAdmission;
assignment: WorkerLaunchAssignment;
};
@@ -261,20 +265,19 @@ export function buildWorkerConnectParams(
export function parseWorkerLaunchDescriptor(value: unknown): WorkerLaunchDescriptor {
if (
!isRecord(value) ||
!hasExactKeys(value, ["version", "socketPath", "admission", "assignment"]) ||
value.version !== LAUNCH_VERSION ||
!isIdentifier(value.socketPath) ||
!path.isAbsolute(value.socketPath)
!hasExactKeys(value, ["version", "connectionEndpoint", "admission", "assignment"]) ||
value.version !== LAUNCH_VERSION
) {
throw new Error("invalid worker launch descriptor");
}
const connectionEndpoint = parseWorkerConnectionEndpoint(value.connectionEndpoint);
const assignment = parseAssignment(value.assignment);
if (!assignment || !isRecord(value.admission)) {
if (!connectionEndpoint || !assignment || !isRecord(value.admission)) {
throw new Error("invalid worker launch descriptor");
}
const candidate: WorkerLaunchDescriptor = {
version: LAUNCH_VERSION,
socketPath: value.socketPath,
connectionEndpoint,
admission: value.admission as WorkerLaunchAdmission,
assignment,
};
+17 -14
View File
@@ -19,6 +19,10 @@ import {
toWorkerConnectionError,
type WorkerConnectionOptions,
} from "./worker-connection-contract.js";
import {
resolveWorkerConnectionTarget,
WorkerConnectionEndpointError,
} from "./worker-connection-endpoint.js";
import { closeInvalidWorkerFrame } from "./worker-connection-frames.js";
const RETRYABLE_CLOSE_REASONS = new Set<WorkerProtocolCloseReason>([
@@ -68,25 +72,18 @@ export function isRetryableWorkerCloseReason(reason: WorkerProtocolCloseReason):
return RETRYABLE_CLOSE_REASONS.has(reason);
}
function workerSocketUrl(socketPath: string): string {
if (!socketPath.startsWith("/")) {
throw new Error("worker gateway socket path must be absolute");
}
if (socketPath.includes(":")) {
throw new Error("worker gateway socket path must not contain a colon");
}
return `ws+unix://${socketPath}:/`;
}
export function connectWorkerConnectionAttempt(
options: WorkerConnectionAttemptOptions,
): Promise<WorkerHelloOk> {
const connectionOptions = options.connectionOptions;
const target = resolveWorkerConnectionTarget(connectionOptions.endpoint);
const socketOptions = {
...target.options,
maxPayload: WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES,
};
const socket = connectionOptions.createSocket
? connectionOptions.createSocket(workerSocketUrl(connectionOptions.socketPath))
: new WebSocket(workerSocketUrl(connectionOptions.socketPath), {
maxPayload: WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES,
});
? connectionOptions.createSocket(target.url, socketOptions)
: new WebSocket(target.url, socketOptions);
options.onSocket(socket);
const admissionId = randomUUID();
let admitted = false;
@@ -121,6 +118,12 @@ export function connectWorkerConnectionAttempt(
socket.close();
return;
}
const tlsError = target.validateSocket(socket);
if (tlsError) {
rejectAttempt(new WorkerConnectionEndpointError(tlsError.message));
socket.close(1008, tlsError.message);
return;
}
options.onAdmitting();
const frame: WorkerConnectRequestFrame = {
type: "req",
+4 -3
View File
@@ -1,5 +1,5 @@
import { toStructuredErrorObject } from "@openclaw/normalization-core/error-coercion";
import type { WebSocket } from "ws";
import type { ClientOptions, WebSocket } from "ws";
import type {
WorkerConnectParams,
WorkerHeartbeatParams,
@@ -7,6 +7,7 @@ import type {
WorkerProtocolCloseReason,
} from "../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { BackoffPolicy } from "../infra/backoff.js";
import type { WorkerConnectionEndpoint } from "./worker-connection-endpoint.js";
const FENCED_CLOSE_REASONS = new Set<WorkerProtocolCloseReason>([
"credential-replaced",
@@ -37,13 +38,13 @@ export type WorkerConnectionExit =
| { kind: "stopped" };
export type WorkerConnectionOptions = {
socketPath: string;
endpoint: WorkerConnectionEndpoint;
connectParams: WorkerConnectParams;
reconnectBackoff?: BackoffPolicy;
admissionTimeoutMs?: number;
admissionDeadlineMs?: number;
requestTimeoutMs?: number;
createSocket?: (url: string) => WebSocket;
createSocket?: (url: string, options: ClientOptions) => WebSocket;
heartbeatStatus?: () => WorkerHeartbeatParams["status"];
};
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { CertMeta, WebSocket } from "ws";
import {
parseWorkerConnectionEndpoint,
resolveWorkerConnectionTarget,
} from "./worker-connection-endpoint.js";
describe("worker connection endpoint", () => {
it("resolves Unix sockets through the existing ws+unix carrier", () => {
const endpoint = parseWorkerConnectionEndpoint({
kind: "unix",
socketPath: "/tmp/openclaw-worker/gateway.sock",
});
expect(endpoint).toBeDefined();
expect(resolveWorkerConnectionTarget(endpoint!)).toMatchObject({
url: "ws+unix:///tmp/openclaw-worker/gateway.sock:/",
options: {},
});
});
it("applies the canonical TLS pin policy to public worker URLs", () => {
const fingerprint = "ab".repeat(32);
const endpoint = parseWorkerConnectionEndpoint({
kind: "websocket",
url: "wss://gateway.example/tenant/__openclaw__/worker",
tlsFingerprint: fingerprint,
});
expect(endpoint).toBeDefined();
const target = resolveWorkerConnectionTarget(endpoint!);
const checkServerIdentity = (hostname: string, cert: CertMeta) =>
target.options.checkServerIdentity?.(hostname, cert);
expect(target.options.rejectUnauthorized).toBe(false);
expect(
checkServerIdentity("gateway.example", {
fingerprint256: fingerprint,
} as unknown as CertMeta),
).toBeUndefined();
expect(
checkServerIdentity("gateway.example", {
fingerprint256: "cd".repeat(32),
} as unknown as CertMeta),
).toEqual(new Error("Server TLS fingerprint mismatch"));
const socket = {
_socket: { getPeerCertificate: () => ({ fingerprint256: fingerprint }) },
} as unknown as WebSocket;
expect(target.validateSocket(socket)).toBeNull();
});
it("rejects public plaintext while retaining the private-network break-glass", () => {
const endpoint = {
kind: "websocket" as const,
url: "ws://gateway.example/__openclaw__/worker",
};
expect(() => resolveWorkerConnectionTarget(endpoint, {})).toThrow("SECURITY ERROR");
expect(() =>
resolveWorkerConnectionTarget(endpoint, { OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1" }),
).not.toThrow();
});
});
+122
View File
@@ -0,0 +1,122 @@
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { ClientOptions, WebSocket } from "ws";
import {
GatewayWebSocketTransportConfigurationError,
resolveGatewayWebSocketTransport,
} from "../../packages/gateway-client/src/websocket-transport.js";
import { WORKER_PUBLIC_INGRESS_PATH } from "../../packages/gateway-protocol/src/schema/worker-admission.js";
import { WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH } from "../../packages/gateway-protocol/src/schema/worker-protocol-primitives.js";
export class WorkerConnectionEndpointError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkerConnectionEndpointError";
}
}
export type WorkerConnectionEndpoint =
| { kind: "unix"; socketPath: string }
| { kind: "websocket"; url: string; tlsFingerprint?: string };
function hasExactKeys(value: Record<string, unknown>, required: string[], optional: string[] = []) {
const allowed = new Set([...required, ...optional]);
return (
required.every((key) => key in value) && Object.keys(value).every((key) => allowed.has(key))
);
}
function parseUnixEndpoint(value: Record<string, unknown>): WorkerConnectionEndpoint | undefined {
if (
!hasExactKeys(value, ["kind", "socketPath"]) ||
value.kind !== "unix" ||
typeof value.socketPath !== "string" ||
value.socketPath.length > WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH ||
!path.isAbsolute(value.socketPath) ||
value.socketPath.includes(":")
) {
return undefined;
}
return { kind: "unix", socketPath: value.socketPath };
}
function parseWebSocketEndpoint(
value: Record<string, unknown>,
): WorkerConnectionEndpoint | undefined {
if (
!hasExactKeys(value, ["kind", "url"], ["tlsFingerprint"]) ||
value.kind !== "websocket" ||
typeof value.url !== "string" ||
value.url.length > 4_096 ||
(value.tlsFingerprint !== undefined &&
(typeof value.tlsFingerprint !== "string" ||
value.tlsFingerprint.trim().length === 0 ||
value.tlsFingerprint.length > 256))
) {
return undefined;
}
let url: URL;
try {
url = new URL(value.url);
} catch {
return undefined;
}
if (
(url.protocol !== "ws:" && url.protocol !== "wss:") ||
url.username !== "" ||
url.password !== "" ||
url.search !== "" ||
url.hash !== "" ||
!url.pathname.endsWith(WORKER_PUBLIC_INGRESS_PATH) ||
(value.tlsFingerprint !== undefined && url.protocol !== "wss:")
) {
return undefined;
}
return {
kind: "websocket",
url: value.url,
...(value.tlsFingerprint === undefined ? {} : { tlsFingerprint: value.tlsFingerprint }),
};
}
export function parseWorkerConnectionEndpoint(
value: unknown,
): WorkerConnectionEndpoint | undefined {
if (!isRecord(value)) {
return undefined;
}
return parseUnixEndpoint(value) ?? parseWebSocketEndpoint(value);
}
type WorkerConnectionTarget = {
url: string;
options: ClientOptions;
validateSocket(socket: WebSocket): Error | null;
};
export function resolveWorkerConnectionTarget(
endpoint: WorkerConnectionEndpoint,
env: NodeJS.ProcessEnv = process.env,
): WorkerConnectionTarget {
if (endpoint.kind === "unix") {
return {
url: `ws+unix://${endpoint.socketPath}:/`,
options: {},
validateSocket: () => null,
};
}
try {
const transport = resolveGatewayWebSocketTransport({
url: endpoint.url,
tlsFingerprint: endpoint.tlsFingerprint,
env,
options: {},
});
return { url: endpoint.url, ...transport };
} catch (error) {
if (error instanceof GatewayWebSocketTransportConfigurationError) {
throw new WorkerConnectionEndpointError(error.message);
}
throw error;
}
}
+23 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import {
GATEWAY_CLIENT_IDS,
@@ -14,6 +14,7 @@ import type {
WorkerInferenceTerminalFrame,
} from "../../packages/gateway-protocol/src/schema/worker-inference.js";
import { toWorkerConnectionError } from "./worker-connection-contract.js";
import { WorkerConnectionEndpointError } from "./worker-connection-endpoint.js";
import { WorkerConnectionFrameDispatcher } from "./worker-connection-frames.js";
import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js";
@@ -44,7 +45,7 @@ const FRAME_CONNECT_PARAMS: WorkerConnectParams = {
function createIdleConnection() {
return createWorkerConnection({
socketPath: "ws://127.0.0.1:1",
endpoint: { kind: "unix", socketPath: "/tmp/worker-listener-isolation.sock" },
connectParams: {
minProtocol: 1,
maxProtocol: 1,
@@ -131,6 +132,26 @@ function installThrowingThenHealthyListeners(connection: ReturnType<typeof creat
return { observed, throwingCalls: () => throwingCalls };
}
describe("worker connection endpoint failures", () => {
it("fails insecure public endpoints without entering reconnect backoff", async () => {
const createSocket = vi.fn();
const connection = createWorkerConnection({
endpoint: {
kind: "websocket",
url: "ws://gateway.example/__openclaw__/worker",
},
connectParams: FRAME_CONNECT_PARAMS,
createSocket,
admissionDeadlineMs: 60_000,
reconnectBackoff: { initialMs: 30_000, maxMs: 30_000, factor: 1, jitter: 0 },
});
await expect(connection.start()).rejects.toBeInstanceOf(WorkerConnectionEndpointError);
expect(connection.state).toMatchObject({ kind: "failed" });
expect(createSocket).not.toHaveBeenCalled();
});
});
describe("worker connection error coercion", () => {
it("preserves structured non-Error causes", () => {
const cause = { code: "ECONNRESET", status: 503 };
+5
View File
@@ -42,6 +42,7 @@ import {
type WorkerConnectionState,
type WorkerFencedReason,
} from "./worker-connection-contract.js";
import { WorkerConnectionEndpointError } from "./worker-connection-endpoint.js";
import { WorkerConnectionFrameDispatcher } from "./worker-connection-frames.js";
export {
@@ -287,6 +288,10 @@ export class WorkerConnection {
this.handleAdmissionFailure(error);
throw error;
}
if (error instanceof WorkerConnectionEndpointError) {
this.finishFailed(error);
throw error;
}
if (this.isTerminal()) {
throw this.terminalError();
}
@@ -316,8 +316,8 @@ export class ComposedGatewayHarness {
const epoch = params.epoch ?? this.epoch;
const credential = params.admissionProof ?? CREDENTIAL;
return {
version: 2,
socketPath: this.socketPath,
version: 3,
connectionEndpoint: { kind: "unix", socketPath: this.socketPath },
admission: {
environmentId: ENVIRONMENT_ID,
credential,
@@ -354,7 +354,7 @@ export class ComposedGatewayHarness {
const descriptor = this.createDescriptor(params);
const epoch = descriptor.admission.ownerEpoch;
const connection = createWorkerConnection({
socketPath: this.socketPath,
endpoint: { kind: "unix", socketPath: this.socketPath },
connectParams: buildWorkerConnectParams(descriptor),
admissionTimeoutMs: 1_000,
admissionDeadlineMs: 5_000,
+9 -9
View File
@@ -808,8 +808,8 @@ class FakeWorkerGateway {
function descriptor(socketPath: string, workspaceDir: string): WorkerLaunchDescriptor {
return {
version: 2,
socketPath,
version: 3,
connectionEndpoint: { kind: "unix", socketPath },
admission: {
environmentId: "worker-environment",
credential: CREDENTIAL,
@@ -1026,7 +1026,7 @@ describe("worker runtime", () => {
silenceSessionSpawnResponses: 2,
});
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
requestTimeoutMs: 25,
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
@@ -1451,7 +1451,7 @@ describe("worker reconnect clients", () => {
it("isolates ready listener failures while admitting the worker and starting heartbeats", async () => {
const { gateway, launch } = await setup({ heartbeatIntervalMs: 1 });
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
});
let healthyReadyCalls = 0;
@@ -1474,7 +1474,7 @@ describe("worker reconnect clients", () => {
it("fails closed when the overall admission deadline expires", async () => {
const { gateway, launch } = await setup({ admissionFailure: "gateway-unavailable" });
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
admissionTimeoutMs: 25,
admissionDeadlineMs: 250,
@@ -1499,7 +1499,7 @@ describe("worker reconnect clients", () => {
it("times out a silent admission attempt and admits on reconnect", async () => {
const { gateway, launch } = await setup({ ignoreFirstAdmission: true });
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
admissionTimeoutMs: 25,
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
@@ -1518,7 +1518,7 @@ describe("worker reconnect clients", () => {
heartbeatIntervalMs: 1,
});
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
requestTimeoutMs: 25,
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
@@ -1538,7 +1538,7 @@ describe("worker reconnect clients", () => {
silenceFirstInference: true,
});
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
requestTimeoutMs: 40,
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
@@ -1590,7 +1590,7 @@ describe("worker reconnect clients", () => {
it("settles an in-flight commit and a later live emit after stop", async () => {
const { gateway, launch } = await setup({ silenceFirstTranscript: true });
const connection = createWorkerConnection({
socketPath: gateway.socketPath,
endpoint: { kind: "unix", socketPath: gateway.socketPath },
connectParams: buildWorkerConnectParams(launch),
requestTimeoutMs: 5_000,
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
+1 -1
View File
@@ -63,7 +63,7 @@ export async function runWorkerDescriptor(
let resultFenceAcked = false;
let forcedStopTimer: NodeJS.Timeout | undefined;
const connection = createWorkerConnection({
socketPath: descriptor.socketPath,
endpoint: descriptor.connectionEndpoint,
connectParams: buildWorkerConnectParams(descriptor),
});
const abortFromCaller = () => {