mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
feat(workers): run device sessions from Gateway bundles (#124037)
* feat(workers): run device sessions from Gateway bundles Install the current Gateway bundle before a device environment becomes ready, verify it at attach and tunnel boundaries, launch only from the immutable namespaced bundle directory, and retire stale environments for idempotent reprovisioning. Remove the local execution mode and preserve the node-local build claim only as temporary inventory metadata for the final projection/cleanup slice. * docs(runners): record Gateway bundle cutover * test(ci): repair runner validation fixtures # Conflicts: # src/scripts/test-projects.test.ts * fix(workers): surface outdated node recovery Keep legacy runner inventory diagnostic-only while exposing the update-and-reconnect action through node, environment, provider, placement, and Control UI surfaces. * fix(workers): reject legacy inventory with recovery * fix(workers): bundle worker deploy closure * test(workers): close bundle cutover gates * fix(workers): compose browser runtime at build * fix(workers): satisfy bundle cutover gates * fix(workers): route temp runtime through infra * docs(workers): align bundle host guidance * fix(ui): fence outdated session destinations
This commit is contained in:
committed by
GitHub
parent
eb13f5719f
commit
78502eda6d
@@ -1945,6 +1945,7 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let desktop: Bool?
|
||||
public let issues: [[String: AnyCodable]]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
public init(
|
||||
@@ -1961,6 +1962,7 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
desktop: Bool? = nil,
|
||||
issues: [[String: AnyCodable]]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
self.id = id
|
||||
@@ -1976,6 +1978,7 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.desktop = desktop
|
||||
self.issues = issues
|
||||
self.worker = worker
|
||||
}
|
||||
|
||||
@@ -1993,6 +1996,7 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
case trust
|
||||
case capabilities
|
||||
case desktop
|
||||
case issues
|
||||
case worker
|
||||
}
|
||||
}
|
||||
@@ -2029,6 +2033,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let desktop: Bool?
|
||||
public let issues: [[String: AnyCodable]]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
public init(
|
||||
@@ -2045,6 +2050,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
desktop: Bool? = nil,
|
||||
issues: [[String: AnyCodable]]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
self.id = id
|
||||
@@ -2060,6 +2066,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.desktop = desktop
|
||||
self.issues = issues
|
||||
self.worker = worker
|
||||
}
|
||||
|
||||
@@ -2077,6 +2084,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
case trust
|
||||
case capabilities
|
||||
case desktop
|
||||
case issues
|
||||
case worker
|
||||
}
|
||||
}
|
||||
@@ -2113,6 +2121,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let desktop: Bool?
|
||||
public let issues: [[String: AnyCodable]]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
public init(
|
||||
@@ -2129,6 +2138,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
desktop: Bool? = nil,
|
||||
issues: [[String: AnyCodable]]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
self.id = id
|
||||
@@ -2144,6 +2154,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.desktop = desktop
|
||||
self.issues = issues
|
||||
self.worker = worker
|
||||
}
|
||||
|
||||
@@ -2161,6 +2172,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
case trust
|
||||
case capabilities
|
||||
case desktop
|
||||
case issues
|
||||
case worker
|
||||
}
|
||||
}
|
||||
@@ -2213,6 +2225,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let desktop: Bool?
|
||||
public let issues: [[String: AnyCodable]]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
public init(
|
||||
@@ -2229,6 +2242,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
desktop: Bool? = nil,
|
||||
issues: [[String: AnyCodable]]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
self.id = id
|
||||
@@ -2244,6 +2258,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.desktop = desktop
|
||||
self.issues = issues
|
||||
self.worker = worker
|
||||
}
|
||||
|
||||
@@ -2261,6 +2276,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
case trust
|
||||
case capabilities
|
||||
case desktop
|
||||
case issues
|
||||
case worker
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Distinct OPENCLAW_* names in production source under src, packages, and extensions.
|
||||
# Ratchet: lower this number when cleanup removes names; never raise it without owner approval.
|
||||
# One-time owner-approved increase: 502 -> 503.
|
||||
# One atomic private CUA driver endpoint replaces two uncounted split facts.
|
||||
503
|
||||
# Worker bundle installation no longer injects OPENCLAW_STATE_DIR into a remote npm process.
|
||||
502
|
||||
|
||||
@@ -107,7 +107,7 @@ Profile fields:
|
||||
| Key | Meaning |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | Worker provider id registered by a plugin (`crabbox` for the bundled plugin). |
|
||||
| `install` | `bundle` (default) ships the running Gateway's build; `npm` installs the exact released Gateway version with pinned integrity. `npm` requires the Gateway to run from a packaged release. |
|
||||
| `install` | `bundle` (default) ships the running Gateway's worker executable; `npm` derives the same executable from the exact released Gateway package with pinned integrity. `npm` requires the Gateway to run from a packaged release. |
|
||||
| `settings` | Provider-owned JSON. For crabbox: `provider` (backend), `class` (machine class), `ttl`, `idleTimeout` (Go durations), optional `setup`, optional `desktop` (boolean), and absolute `binary` path. OpenClaw forces public SSH and disables managed Tailscale for these leases. |
|
||||
|
||||
Crabbox inspect reports a primary SSH port and may advertise ordered fallback ports. OpenClaw persists that order across Gateway restarts. Its shared pinned SSH transport rotates candidates only for replay-safe operations: idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed on their current candidate and are not replayed on another port. OpenClaw never invents an unadvertised port. If your network policy pins SSH ingress, allow at least one advertised Crabbox candidate.
|
||||
@@ -120,8 +120,8 @@ OpenClaw derives one canonical `cbx_...` lease ID from the durable provision ope
|
||||
|
||||
### Install channels
|
||||
|
||||
- **`bundle`** packs the running Gateway's `dist`, a pruned `package.json`, and any workspace packages the build references, all covered by a content hash. The box verifies the pristine bundle against that hash, then installs production npm dependencies (scripts disabled). This is how you run a dev build on a worker.
|
||||
- **`npm`** proves the release exists on the public registry, pins its SHA-512 integrity, and installs `openclaw@<version>` matching the Gateway exactly.
|
||||
- **`bundle`** ships one dedicated worker executable whose complete JavaScript dependency closure is bundled and content-hashed by the Gateway. It does not ship the normal OpenClaw package manifest or a dependency-install recipe. The box verifies and publishes those exact bytes without installing packages or running lifecycle scripts. This is how you run a dev build on a worker.
|
||||
- **`npm`** proves the release exists on the public registry, pins its SHA-512 integrity, and extracts the same dedicated worker executable from `openclaw@<version>` without materializing the package's dependency tree.
|
||||
|
||||
### Verify the profile
|
||||
|
||||
|
||||
+15
-9
@@ -420,8 +420,7 @@ node does not advertise this command yet, so its rows remain view-only.
|
||||
|
||||
### Host OpenClaw sessions
|
||||
|
||||
A headless node host can separately opt into full OpenClaw session hosting from
|
||||
its local installation:
|
||||
A headless node host can separately opt into full OpenClaw session hosting:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -431,13 +430,20 @@ its local installation:
|
||||
}
|
||||
```
|
||||
|
||||
Restart the node host after enabling this setting. At startup it freezes the
|
||||
exact OpenClaw version, worker-bundle hash, and worker protocol features of its
|
||||
own installation in the connection handshake, then repeats that build in its
|
||||
live runner inventory. The Gateway reports prepared session-host eligibility
|
||||
only while those declarations match, and provisioning requires the node and
|
||||
Gateway versions to match exactly. If they differ, update the node before
|
||||
retrying.
|
||||
Restart the node host after enabling this setting. On the first session dispatch
|
||||
for a Gateway build, the node downloads one sealed worker artifact from that
|
||||
paired Gateway, verifies its exact content hash, and publishes it atomically
|
||||
under the Gateway-namespaced node-host bundle root. The artifact already
|
||||
contains its complete JavaScript dependency closure; the node does not install
|
||||
packages or execute lifecycle scripts. Later turns reuse the immutable artifact
|
||||
while its receipt still matches the Gateway's current build.
|
||||
|
||||
Node hosts must support the current private worker-supervisor dialect before
|
||||
they can host sessions. An older connected host remains visible but disabled in
|
||||
the session picker. Update OpenClaw on that device and reconnect it; for a
|
||||
headless node, run `openclaw update` followed by `openclaw node restart`. The
|
||||
Gateway does not fall back to the node's local OpenClaw package or an older
|
||||
supervisor dialect.
|
||||
|
||||
This setting enables supervised session turns on the paired device, including
|
||||
Gateway-owned workspace transfer and result reconciliation. Each node runs at
|
||||
|
||||
+22
-18
@@ -25,7 +25,7 @@ advances a milestone.
|
||||
| F | Real-wire session boundary harness | landed | #121212 |
|
||||
| 5 | Public worker ingress path | landed | #122578, #122643 |
|
||||
| 6 | Node worker provider (device runners) | in progress | #122683, #122769, #122829, #122939, #123013, #123033, #122966, #123157, #123280, #123612, #123641, #123665, #123673, #123700, #123696, #123785, #123859, #123889, #123901 |
|
||||
| 7 | Bundle push consent + runner updates | in progress | #123985 |
|
||||
| 7 | Bundle push consent + runner updates | in progress | #123985, #124037 |
|
||||
| 8 | Stop-and-continue moves | not started | — |
|
||||
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
|
||||
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
|
||||
@@ -230,16 +230,15 @@ box (the machine is the boundary).
|
||||
Milestone 6 now has the public worker ingress, transport-neutral launch
|
||||
descriptor, durable node-host supervisor, private launch/status/cancel dialect,
|
||||
bounded terminal receipts, and the Gateway launch replay/poll/cancel adapter.
|
||||
A node freezes its optional local worker build in the connection handshake and
|
||||
repeats it in one atomic, reconnect-scoped private runner inventory with the
|
||||
supervisor dialect. The Gateway requires those semantic build identities to
|
||||
match. Public node and environment projections expose only `sessionHost`; a
|
||||
read-scoped topology invalidation makes clients refetch without exposing the
|
||||
build. Provider eligibility and new launch selection require the exact handshake,
|
||||
while status and cancellation reacquire only the current supervisor proof and use
|
||||
the durable launch identity so an upgrade cannot strand an existing worker.
|
||||
Node-local opt-in advertises the current installation; default nodes remain
|
||||
non-hosts. The supervisor now owns two atomic durable capacity slots, bounded
|
||||
A node publishes one atomic, reconnect-scoped private runner inventory with the
|
||||
supervisor dialect and current capacity. The temporary local build claim remains
|
||||
inventory metadata only; milestone 7 makes the durable Gateway bundle receipt
|
||||
the sole execution authority. Public node and environment projections expose
|
||||
only `sessionHost`; a read-scoped topology invalidation makes clients refetch
|
||||
without exposing build identity. Status and cancellation reacquire the current
|
||||
supervisor proof and use the durable launch identity so an upgrade cannot strand
|
||||
an existing worker. Node-local opt-in advertises capacity; default nodes remain
|
||||
non-hosts. The supervisor owns two atomic durable capacity slots, bounded
|
||||
10-second admission, restart reconciliation, and full/free inventory edges.
|
||||
Device dormancy expiry and terminal launch/environment retention bound durable
|
||||
rows. Node workspace cleanup waits for a full reconnect-scoped Gateway retain
|
||||
@@ -247,9 +246,9 @@ snapshot, unions that authority with node-local launch and operation ownership,
|
||||
and then removes retired generations, transfer siblings, unreachable manifests,
|
||||
and empty workspace parents in bounded passes. The Gateway bundle producer
|
||||
also prunes unreferenced local tarballs only after a successful current build,
|
||||
while preserving hashes named by durable environments and placements. Milestone
|
||||
7 upgrades this to Gateway-pinned, namespaced bundle bytes. Isolation, checkout ownership, and
|
||||
durable offline recovery actions remain milestone 6 work.
|
||||
while preserving hashes named by durable environments and placements. Isolation,
|
||||
checkout ownership, and durable offline recovery actions remain milestone 6
|
||||
work.
|
||||
|
||||
### Trust model (operator-decided, v1)
|
||||
|
||||
@@ -318,10 +317,15 @@ it cannot rot into approval fatigue or silent surprise:
|
||||
silently.
|
||||
|
||||
The first milestone 7 slice (#123985) adds the private paired-channel install
|
||||
command, one-use Gateway download capability, bounded archive validation,
|
||||
script-disabled dependency materialization, and atomic namespaced publication.
|
||||
Device provisioning continues to use the local-build claim until the next slice
|
||||
cuts it over and removes that temporary path.
|
||||
command, one-use Gateway download capability, bounded archive validation, and
|
||||
atomic namespaced publication. The cutover slice (#124037) packages the complete
|
||||
worker JavaScript dependency closure into one dedicated, hash-covered executable,
|
||||
installs that exact Gateway artifact before device environments become ready,
|
||||
requires its durable receipt across attach, admission, placement, tunnel, and
|
||||
launch, retires stale environments for idempotent reprovisioning, and removes
|
||||
local-package execution. The remaining
|
||||
slice separates inventory consent/capacity from installed bundle status, exposes
|
||||
the installed version on the devices page, and bounds superseded node bundle GC.
|
||||
|
||||
### Projects read model (milestone 4 foundation)
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare const coreBundle: {
|
||||
getUserAgent(): string;
|
||||
};
|
||||
|
||||
export default coreBundle;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Build-visible bridge for playwright-core's private User-Agent helper.
|
||||
import coreBundle from "playwright-core/lib/coreBundle";
|
||||
|
||||
export default coreBundle;
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Playwright runtime loader.
|
||||
*
|
||||
* Loads playwright-core through CommonJS require so the browser plugin can use
|
||||
* the dependency from the packaged runtime boundary.
|
||||
* Static package imports keep the worker deploy build's executable closure visible
|
||||
* to the bundler while normal package builds may still externalize the dependency.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import playwrightCoreDefault from "playwright-core";
|
||||
import type * as PlaywrightCore from "playwright-core";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const playwrightCoreBundle = require("playwright-core/lib/coreBundle") as {
|
||||
getUserAgent: () => string;
|
||||
};
|
||||
import coreBundle from "./playwright-core-bundle.runtime.mjs";
|
||||
|
||||
/** Runtime playwright-core module instance. */
|
||||
export const playwrightCore = require("playwright-core") as typeof PlaywrightCore;
|
||||
export const playwrightCore = playwrightCoreDefault as typeof PlaywrightCore;
|
||||
|
||||
/** Dependency-owned User-Agent used by Playwright's native CDP WebSocket transport. */
|
||||
export const getPlaywrightUserAgent = playwrightCoreBundle.getUserAgent;
|
||||
export const getPlaywrightUserAgent = (coreBundle as { getUserAgent: () => string }).getUserAgent;
|
||||
|
||||
@@ -187,6 +187,29 @@ describe("worker environment protocol schemas", () => {
|
||||
status: "available",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(EnvironmentSummarySchema, {
|
||||
id: "node:outdated",
|
||||
type: "node",
|
||||
status: "available",
|
||||
issues: [
|
||||
{
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(EnvironmentSummarySchema, {
|
||||
id: "node:outdated",
|
||||
type: "node",
|
||||
status: "available",
|
||||
issues: [{ code: "update-required", action: "run-legacy-worker" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(EnvironmentSummarySchema, {
|
||||
...workerSummary("ready", "available"),
|
||||
|
||||
@@ -47,6 +47,14 @@ export const WorkerDesktopAppIdSchema = Type.Union([
|
||||
Type.Literal("terminal"),
|
||||
]);
|
||||
|
||||
/** Actionable issue attached only to runtime targets that need operator intervention. */
|
||||
export const RuntimeTargetIssueSchema = closedObject({
|
||||
code: Type.Literal("update-required"),
|
||||
action: Type.Literal("update-and-reconnect"),
|
||||
updateCommand: Type.Literal("openclaw update"),
|
||||
headlessReconnectCommand: Type.Literal("openclaw node restart"),
|
||||
});
|
||||
|
||||
/** Worker-only lifecycle metadata layered onto the existing environment projection. */
|
||||
export const WorkerEnvironmentMetadataSchema = closedObject({
|
||||
providerId: NonEmptyString,
|
||||
@@ -78,6 +86,7 @@ function createEnvironmentSummarySchema() {
|
||||
trust: Type.Optional(EnvironmentTrustSchema),
|
||||
capabilities: Type.Optional(Type.Array(NonEmptyString)),
|
||||
desktop: Type.Optional(Type.Boolean()),
|
||||
issues: Type.Optional(Type.Array(RuntimeTargetIssueSchema, { minItems: 1, maxItems: 8 })),
|
||||
worker: Type.Optional(WorkerEnvironmentMetadataSchema),
|
||||
});
|
||||
}
|
||||
@@ -154,6 +163,7 @@ export type EnvironmentStatus = Static<typeof EnvironmentStatusSchema>;
|
||||
export type WorkerEnvironmentState = Static<typeof WorkerEnvironmentStateSchema>;
|
||||
export type WorkerTunnelStatus = Static<typeof WorkerTunnelStatusSchema>;
|
||||
export type WorkerDesktopAppId = Static<typeof WorkerDesktopAppIdSchema>;
|
||||
export type RuntimeTargetIssue = Static<typeof RuntimeTargetIssueSchema>;
|
||||
export type WorkerEnvironmentMetadata = Static<typeof WorkerEnvironmentMetadataSchema>;
|
||||
export type EnvironmentSummary = Static<typeof EnvironmentSummarySchema>;
|
||||
export type EnvironmentsCreateParams = Static<typeof EnvironmentsCreateParamsSchema>;
|
||||
|
||||
@@ -5,8 +5,10 @@ import fs from "node:fs";
|
||||
import module from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse, type Node as AcornNode } from "acorn";
|
||||
|
||||
const DEFAULT_ENTRYPOINTS = ["dist/entry.js", "dist/cli/run-main.js"];
|
||||
const WORKER_DEPLOY_ENTRYPOINT = "dist/worker/worker.mjs";
|
||||
const DEFAULT_GATEWAY_RUN_CHUNK_MAX_BYTES = 70 * 1024;
|
||||
const GATEWAY_RUN_CHUNK_MARKER_SETS = [
|
||||
["const GATEWAY_AUTH_MODES", "function addGatewayRunCommand"],
|
||||
@@ -29,6 +31,7 @@ type CliBootstrapCheckParams = {
|
||||
entrypoints?: string[];
|
||||
distDir?: string;
|
||||
gatewayRunChunkMaxBytes?: number;
|
||||
workerEntrypoint?: string;
|
||||
fs?: typeof fs;
|
||||
logger?: { error(message: string): void };
|
||||
};
|
||||
@@ -74,6 +77,98 @@ export function listStaticImportSpecifiers(source: string) {
|
||||
return [...source.matchAll(STATIC_IMPORT_RE)].map((match) => match.groups?.specifier ?? "");
|
||||
}
|
||||
|
||||
function literalString(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const node = value as { type?: unknown; value?: unknown };
|
||||
return node.type === "Literal" && typeof node.value === "string" ? node.value : undefined;
|
||||
}
|
||||
|
||||
function isRequireCallName(value: unknown): boolean {
|
||||
return typeof value === "string" && /^(?:require|_*require\d*)$/u.test(value);
|
||||
}
|
||||
|
||||
function isRequireLikeCallee(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const node = value as Record<string, unknown>;
|
||||
if (node.type === "Identifier") {
|
||||
return isRequireCallName(node.name);
|
||||
}
|
||||
if (node.type !== "CallExpression") {
|
||||
return false;
|
||||
}
|
||||
const callee = node.callee;
|
||||
if (!callee || typeof callee !== "object" || Array.isArray(callee)) {
|
||||
return false;
|
||||
}
|
||||
const member = callee as Record<string, unknown>;
|
||||
if (member.type === "Identifier" && member.name === "createRequire") {
|
||||
return true;
|
||||
}
|
||||
const property = member.property;
|
||||
return (
|
||||
member.type === "MemberExpression" &&
|
||||
property !== null &&
|
||||
typeof property === "object" &&
|
||||
!Array.isArray(property) &&
|
||||
(property as Record<string, unknown>).type === "Identifier" &&
|
||||
(property as Record<string, unknown>).name === "createRequire"
|
||||
);
|
||||
}
|
||||
|
||||
function listRuntimeImportSpecifiers(source: string): string[] {
|
||||
const ast = parse(source, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowHashBang: true,
|
||||
});
|
||||
const specifiers: string[] = [];
|
||||
const stack: unknown[] = [ast];
|
||||
while (stack.length > 0) {
|
||||
const value = stack.pop();
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
stack.push(...value);
|
||||
continue;
|
||||
}
|
||||
const node = value as AcornNode & Record<string, unknown>;
|
||||
if (
|
||||
node.type === "ImportDeclaration" ||
|
||||
node.type === "ExportNamedDeclaration" ||
|
||||
node.type === "ExportAllDeclaration" ||
|
||||
node.type === "ImportExpression"
|
||||
) {
|
||||
const specifier = literalString(node.source);
|
||||
if (specifier) {
|
||||
specifiers.push(specifier);
|
||||
}
|
||||
} else if (node.type === "CallExpression") {
|
||||
const callee = node.callee;
|
||||
const args = node.arguments;
|
||||
if (isRequireLikeCallee(callee) && Array.isArray(args)) {
|
||||
const specifier = literalString(args[0]);
|
||||
if (specifier) {
|
||||
specifiers.push(specifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (key === "start" || key === "end" || key === "loc" || key === "range") {
|
||||
continue;
|
||||
}
|
||||
if (child && typeof child === "object") {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(specifiers)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function walkStaticImportGraph(
|
||||
fsImpl: typeof fs,
|
||||
rootDir: string,
|
||||
@@ -251,13 +346,78 @@ export function collectGatewayRunChunkBudgetErrors(params: CliBootstrapCheckPara
|
||||
return errors.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
/** Collects closure and layout errors for the standalone worker deploy artifact. */
|
||||
export function collectWorkerDeployArtifactErrors(params: CliBootstrapCheckParams = {}) {
|
||||
const rootDir = params.rootDir ?? process.cwd();
|
||||
const fsImpl = params.fs ?? fs;
|
||||
const entrypoint = path.resolve(rootDir, params.workerEntrypoint ?? WORKER_DEPLOY_ENTRYPOINT);
|
||||
const artifactDir = path.dirname(entrypoint);
|
||||
const relativeEntrypoint = path.relative(rootDir, entrypoint) || entrypoint;
|
||||
const errors: string[] = [];
|
||||
let source: string;
|
||||
try {
|
||||
const stats = fsImpl.lstatSync(entrypoint);
|
||||
if (stats.isSymbolicLink() || !stats.isFile()) {
|
||||
return [`Worker deploy artifact ${relativeEntrypoint} must be a regular file.`];
|
||||
}
|
||||
source = fsImpl.readFileSync(entrypoint, "utf8");
|
||||
} catch {
|
||||
return [`Worker deploy artifact ${relativeEntrypoint} is missing. Run pnpm build first.`];
|
||||
}
|
||||
try {
|
||||
for (const entry of fsImpl.readdirSync(artifactDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.name === path.basename(entrypoint) ||
|
||||
entry.name === `${path.basename(entrypoint)}.map`
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name === "package.json") {
|
||||
errors.push(
|
||||
"Worker deploy artifact must not contain a dependency manifest or lifecycle scripts.",
|
||||
);
|
||||
} else if (entry.name === "node_modules") {
|
||||
errors.push("Worker deploy artifact must not contain materialized dependencies.");
|
||||
} else if (/\.(?:mjs|node|wasm)$/u.test(entry.name)) {
|
||||
errors.push(
|
||||
`Worker deploy artifact emits unstaged runtime asset ${path.relative(
|
||||
rootDir,
|
||||
path.join(artifactDir, entry.name),
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errors.push(
|
||||
`Worker deploy artifact directory ${path.relative(rootDir, artifactDir)} is unreadable.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
for (const specifier of listRuntimeImportSpecifiers(source)) {
|
||||
if (!isBuiltinSpecifier(specifier)) {
|
||||
errors.push(
|
||||
`Worker deploy artifact ${relativeEntrypoint} retains runtime import "${specifier}" instead of bundling it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Worker deploy artifact ${relativeEntrypoint} is not parseable JavaScript: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
return errors.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the CLI bootstrap import and chunk-budget checks.
|
||||
* Runs the CLI bootstrap import, chunk-budget, and worker deploy checks.
|
||||
*/
|
||||
export function checkCliBootstrapExternalImports(params: CliBootstrapCheckParams = {}) {
|
||||
const errors = [
|
||||
...collectCliBootstrapExternalImportErrors(params),
|
||||
...collectGatewayRunChunkBudgetErrors(params),
|
||||
...collectWorkerDeployArtifactErrors(params),
|
||||
];
|
||||
if (errors.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const WORKER_DEPLOY_BUILD_PLUGIN_NAME = "openclaw:worker-deploy";
|
||||
export const WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID = `${path.resolve("src/worker/worker-deploy-runtime.ts")}?optional-native`;
|
||||
|
||||
const PLAYWRIGHT_PACKAGE_INIT = ` packageRoot = import_path9.default.join(__dirname, "..");
|
||||
packageJSON = require(import_path9.default.join(packageRoot, "package.json"));
|
||||
binPath = import_path9.default.join(packageRoot, "bin");`;
|
||||
const PLAYWRIGHT_BROWSER_REGISTRY_INIT =
|
||||
' registry = new Registry(require(import_path20.default.join(packageRoot, "browsers.json")));';
|
||||
const WORKER_BROWSER_RUNTIME_COMPOSITION = `import { createAttachedBrowserToolRuntime } from "../../extensions/browser/runtime-api.js";
|
||||
export default { createAttachedBrowserToolRuntime };`;
|
||||
|
||||
/** Composes bundled-plugin runtime and removes dependency package reads from the worker build. */
|
||||
export function createWorkerDeployBuildPlugin(rootDir = process.cwd()) {
|
||||
const playwrightRoot = fs.realpathSync(path.resolve(rootDir, "node_modules/playwright-core"));
|
||||
const coreBundlePath = fs.realpathSync(path.join(playwrightRoot, "lib/coreBundle.js"));
|
||||
const browserRuntimeBridgePath = fs.realpathSync(
|
||||
path.resolve("src/worker/worker-deploy-browser-runtime.ts"),
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(playwrightRoot, "package.json"), "utf8"),
|
||||
) as { name: string; version: string };
|
||||
const browsersJson = JSON.parse(
|
||||
fs.readFileSync(path.join(playwrightRoot, "browsers.json"), "utf8"),
|
||||
) as unknown;
|
||||
const replacement = ` packageRoot = __dirname;
|
||||
packageJSON = ${JSON.stringify({ name: packageJson.name, version: packageJson.version })};
|
||||
binPath = packageRoot;`;
|
||||
|
||||
return {
|
||||
name: WORKER_DEPLOY_BUILD_PLUGIN_NAME,
|
||||
load(id: string) {
|
||||
return id === WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID
|
||||
? 'throw new Error("optional host-native dependency unavailable in portable worker runtime");'
|
||||
: null;
|
||||
},
|
||||
transform(this: { error(message: string): never }, code: string, id: string) {
|
||||
let resolvedId: string;
|
||||
try {
|
||||
resolvedId = fs.realpathSync(path.resolve(id));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (resolvedId === browserRuntimeBridgePath) {
|
||||
return WORKER_BROWSER_RUNTIME_COMPOSITION;
|
||||
}
|
||||
if (
|
||||
resolvedId !== coreBundlePath ||
|
||||
!id.replaceAll("\\", "/").endsWith("/playwright-core/lib/coreBundle.js")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!code.includes(PLAYWRIGHT_PACKAGE_INIT) ||
|
||||
!code.includes(PLAYWRIGHT_BROWSER_REGISTRY_INIT)
|
||||
) {
|
||||
this.error("playwright-core package bootstrap changed; update the worker deploy transform");
|
||||
}
|
||||
return code
|
||||
.replace(PLAYWRIGHT_PACKAGE_INIT, replacement)
|
||||
.replace(
|
||||
PLAYWRIGHT_BROWSER_REGISTRY_INIT,
|
||||
` registry = new Registry(${JSON.stringify(browsersJson)});`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -493,9 +493,9 @@ describe("runExecProcess PTY fallback", () => {
|
||||
return call[0];
|
||||
}
|
||||
|
||||
it("falls back when PTY spawn fails", async () => {
|
||||
it("visibly falls back when the portable worker rejects PTY", async () => {
|
||||
supervisorMock.spawn
|
||||
.mockRejectedValueOnce(new Error("pty spawn failed"))
|
||||
.mockRejectedValueOnce(new Error("PTY is unavailable in the portable worker runtime"))
|
||||
.mockImplementationOnce(async (input: SpawnInput) => runtimeManagedRun(input, "ok"));
|
||||
|
||||
const warnings: string[] = [];
|
||||
@@ -504,7 +504,7 @@ describe("runExecProcess PTY fallback", () => {
|
||||
|
||||
expect(outcome.status).toBe("completed");
|
||||
expect(outcome.aggregated).toContain("ok");
|
||||
expect(warnings.join("\n")).toContain("PTY spawn failed");
|
||||
expect(warnings.join("\n")).toContain("PTY is unavailable in the portable worker runtime");
|
||||
expect(spawnInput(0).mode).toBe("pty");
|
||||
expect(spawnInput(1).mode).toBe("child");
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const currentDir = dirname(currentFile);
|
||||
declare const WORKER_DEPLOY_VERSION: string | undefined;
|
||||
|
||||
/**
|
||||
* Detect if we're running as a Bun compiled binary.
|
||||
@@ -96,7 +97,10 @@ interface PackageJson {
|
||||
};
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson;
|
||||
const workerVersion = typeof WORKER_DEPLOY_VERSION === "string" ? WORKER_DEPLOY_VERSION : undefined;
|
||||
const pkg: PackageJson = workerVersion
|
||||
? { name: "openclaw", version: workerVersion }
|
||||
: (JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson);
|
||||
|
||||
const openClawConfigName: string | undefined = pkg.openclawConfig?.name;
|
||||
export const APP_NAME: string = openClawConfigName || "openclaw";
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { decodeHtmlEntities } from "../../shared/html-entities.js";
|
||||
import { getWorkerDeployHighlightJs } from "../../worker/worker-deploy-runtime-registry.js";
|
||||
|
||||
type HighlightJs = {
|
||||
getLanguage(name: string): unknown;
|
||||
@@ -16,6 +17,9 @@ type HighlightJs = {
|
||||
highlightAuto(code: string, languageSubset?: string[]): { value: string };
|
||||
};
|
||||
|
||||
let highlightJsRuntime: HighlightJs | undefined;
|
||||
declare const WORKER_DEPLOY_BUILD: boolean;
|
||||
|
||||
function isHighlightJs(value: unknown): value is HighlightJs {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
@@ -29,14 +33,30 @@ function isHighlightJs(value: unknown): value is HighlightJs {
|
||||
);
|
||||
}
|
||||
|
||||
// highlight.js ships `/// <reference lib="dom" />` in its d.ts, which would
|
||||
// silently re-inject DOM globals into the DOM-free core program. Load it
|
||||
// untyped and validate the narrow API we use instead of importing its types.
|
||||
const highlightJsModule: unknown = createRequire(import.meta.url)("highlight.js");
|
||||
if (!isHighlightJs(highlightJsModule)) {
|
||||
throw new TypeError("highlight.js did not expose the expected Node API");
|
||||
function setHighlightJsRuntime(runtime: unknown): HighlightJs {
|
||||
if (!isHighlightJs(runtime)) {
|
||||
throw new TypeError("highlight.js did not expose the expected Node API");
|
||||
}
|
||||
highlightJsRuntime = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function loadHighlightJsRuntime(): HighlightJs {
|
||||
if (highlightJsRuntime) {
|
||||
return highlightJsRuntime;
|
||||
}
|
||||
const injected = getWorkerDeployHighlightJs();
|
||||
if (injected !== undefined) {
|
||||
return setHighlightJsRuntime(injected);
|
||||
}
|
||||
if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) {
|
||||
throw new Error("worker highlight.js runtime was not registered before use");
|
||||
}
|
||||
// highlight.js ships `/// <reference lib="dom" />` in its d.ts, which would
|
||||
// silently re-inject DOM globals into the DOM-free core program. Load it
|
||||
// untyped and validate the narrow API we use instead of importing its types.
|
||||
return setHighlightJsRuntime(createRequire(import.meta.url)("highlight.js"));
|
||||
}
|
||||
const hljs = highlightJsModule;
|
||||
|
||||
/** Formatter applied to highlighted text segments. */
|
||||
type HighlightFormatter = (text: string) => string;
|
||||
@@ -176,6 +196,7 @@ function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string
|
||||
|
||||
/** Highlights code using an explicit language or highlight.js auto-detection. */
|
||||
export function highlight(code: string, options: HighlightOptions = {}): string {
|
||||
const hljs = loadHighlightJsRuntime();
|
||||
const html = options.language
|
||||
? hljs.highlight(code, {
|
||||
language: options.language,
|
||||
@@ -187,5 +208,5 @@ export function highlight(code: string, options: HighlightOptions = {}): string
|
||||
|
||||
/** Returns whether highlight.js has a registered language by this name. */
|
||||
export function supportsLanguage(name: string): boolean {
|
||||
return hljs.getLanguage(name) !== undefined;
|
||||
return loadHighlightJsRuntime().getLanguage(name) !== undefined;
|
||||
}
|
||||
|
||||
+2
-115
@@ -1,113 +1,4 @@
|
||||
import { Option, type Command } from "commander";
|
||||
import { signalProcessTree } from "../process/kill-tree.js";
|
||||
import {
|
||||
NODE_WORKER_CONNECTION_FAILURE_MESSAGE_TYPE,
|
||||
type NodeWorkerConnectionFailureMessage,
|
||||
} from "../worker/node-supervisor-protocol.js";
|
||||
import type { WorkerCommandLifetime } from "../worker/worker-command.runtime.js";
|
||||
|
||||
const WORKER_START_MESSAGE_TYPE = "openclaw-worker-start-v1";
|
||||
|
||||
function isWorkerStartMessage(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 1 &&
|
||||
(value as { type?: unknown }).type === WORKER_START_MESSAGE_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
function createWorkerIpcLifetime(): WorkerCommandLifetime {
|
||||
if (!process.connected || !process.channel || typeof process.send !== "function") {
|
||||
throw new Error("internal worker IPC mode requires a connected Node IPC channel");
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
let disposed = false;
|
||||
let started = false;
|
||||
let settled = false;
|
||||
let resolveStarted!: (started: boolean) => void;
|
||||
let rejectStarted!: (error: Error) => void;
|
||||
const startedPromise = new Promise<boolean>((resolve, reject) => {
|
||||
resolveStarted = resolve;
|
||||
rejectStarted = reject;
|
||||
});
|
||||
const rejectOrAbort = (error: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
rejectStarted(error);
|
||||
return;
|
||||
}
|
||||
abortController.abort(error);
|
||||
};
|
||||
const onMessage = (message: unknown) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (!isWorkerStartMessage(message) || settled) {
|
||||
rejectOrAbort(new Error("invalid internal worker IPC start message"));
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
settled = true;
|
||||
resolveStarted(true);
|
||||
};
|
||||
const onDisconnect = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolveStarted(false);
|
||||
return;
|
||||
}
|
||||
if (started) {
|
||||
abortController.abort(new Error("worker supervisor lifetime ended"));
|
||||
}
|
||||
};
|
||||
process.on("message", onMessage);
|
||||
process.once("disconnect", onDisconnect);
|
||||
return {
|
||||
started: startedPromise,
|
||||
signal: abortController.signal,
|
||||
reportConnectionFailure: (cause) => {
|
||||
if (disposed || !process.connected || typeof process.send !== "function") {
|
||||
return;
|
||||
}
|
||||
const message: NodeWorkerConnectionFailureMessage = {
|
||||
type: NODE_WORKER_CONNECTION_FAILURE_MESSAGE_TYPE,
|
||||
cause: cause ?? null,
|
||||
};
|
||||
try {
|
||||
process.send(message, () => {});
|
||||
} catch {
|
||||
// The disconnect handler owns worker shutdown when the supervisor is gone.
|
||||
}
|
||||
},
|
||||
terminateOwnedTree: () => {
|
||||
signalProcessTree(process.pid, "SIGKILL", {
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
process.off("message", onMessage);
|
||||
process.off("disconnect", onDisconnect);
|
||||
if (process.connected) {
|
||||
try {
|
||||
process.disconnect?.();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ERR_IPC_DISCONNECTED") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Register the restricted cloud worker runtime entry point. */
|
||||
export function registerWorkerCli(program: Command): void {
|
||||
@@ -116,11 +7,7 @@ export function registerWorkerCli(program: Command): void {
|
||||
.description("Run the restricted cloud worker runtime")
|
||||
.addOption(new Option("--internal-worker-ipc").hideHelp())
|
||||
.action(async (options: { internalWorkerIpc?: boolean }) => {
|
||||
const { runWorkerCommand } = await import("../worker/worker-command.runtime.js");
|
||||
await runWorkerCommand({
|
||||
input: process.stdin,
|
||||
...(options.internalWorkerIpc ? { lifetime: createWorkerIpcLifetime() } : {}),
|
||||
output: process.stdout,
|
||||
});
|
||||
const { runWorkerProcess } = await import("../worker/worker-process.js");
|
||||
await runWorkerProcess({ internalWorkerIpc: options.internalWorkerIpc === true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,8 +226,10 @@ function buildEffectiveKnownNode(entry: {
|
||||
pendingNodePairing?: KnownNodePendingSource;
|
||||
live?: NodeSession;
|
||||
sessionHost: boolean;
|
||||
issues?: NodeListNode["issues"];
|
||||
}): NodeListNode {
|
||||
const { nodeId, devicePairing, nodePairing, pendingNodePairing, live, sessionHost } = entry;
|
||||
const { nodeId, devicePairing, nodePairing, pendingNodePairing, live, sessionHost, issues } =
|
||||
entry;
|
||||
const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing });
|
||||
const lastConnectedAtMs = maxDefinedTimestamp(
|
||||
nodePairing?.lastConnectedAtMs,
|
||||
@@ -297,6 +299,7 @@ function buildEffectiveKnownNode(entry: {
|
||||
),
|
||||
computerUse: live?.computerUse,
|
||||
sessionHost,
|
||||
...(issues?.length ? { issues: [...issues] } : {}),
|
||||
nodePluginTools: live?.nodePluginTools,
|
||||
pathEnv: live?.pathEnv,
|
||||
permissions: live?.permissions ?? nodePairing?.permissions,
|
||||
@@ -346,6 +349,7 @@ export function createKnownNodeCatalog(params: {
|
||||
pendingNodes?: readonly NodePairingPendingRequest[];
|
||||
connectedNodes: readonly NodeSession[];
|
||||
sessionHostNodeIds?: ReadonlySet<string>;
|
||||
issuesByNodeId?: ReadonlyMap<string, NodeListNode["issues"]>;
|
||||
}): KnownNodeCatalog {
|
||||
const devicePairingById = new Map(
|
||||
params.pairedDevices
|
||||
@@ -399,6 +403,7 @@ export function createKnownNodeCatalog(params: {
|
||||
pendingNodePairing,
|
||||
live,
|
||||
sessionHost: params.sessionHostNodeIds?.has(nodeId) === true,
|
||||
issues: params.issuesByNodeId?.get(nodeId),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND,
|
||||
} from "../infra/node-commands.js";
|
||||
import {
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE,
|
||||
type NodeRunnerInventoryIssue,
|
||||
type NodeRunnerInventoryDeclaration,
|
||||
} from "../infra/node-runner-inventory.js";
|
||||
import { sameWorkerBuild, sameWorkerProtocolFeatures } from "../worker/worker-build-identity.js";
|
||||
@@ -72,15 +75,16 @@ export type NodeWorkerSupervisorNodeProof = {
|
||||
clientId: typeof GATEWAY_CLIENT_IDS.NODE_HOST;
|
||||
clientMode: "node";
|
||||
protocolFeature: typeof NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE;
|
||||
/** Immutable build ceiling from the authenticated connection handshake. */
|
||||
/** Node-local session-host claim from the connection handshake; never execution authority. */
|
||||
workerBuild?: WorkerAdmissionHandshake;
|
||||
/** Transient new-launch eligibility; omitted while the node is at capacity. */
|
||||
/** Transient launch capacity declaration; omitted while the node is full. */
|
||||
workerRuns?: WorkerAdmissionHandshake;
|
||||
commands: readonly string[];
|
||||
};
|
||||
|
||||
export type NodeWorkerSupervisorTransport = {
|
||||
listCurrentNodes(): Promise<readonly NodeWorkerSupervisorNodeProof[]>;
|
||||
getIssue?(nodeId: string): NodeRunnerInventoryIssue | undefined;
|
||||
isCurrent(node: NodeWorkerSupervisorNodeProof, requireLaunchEligibility?: boolean): boolean;
|
||||
invoke(params: {
|
||||
node: NodeWorkerSupervisorNodeProof;
|
||||
@@ -96,7 +100,7 @@ export type NodeWorkerSupervisorTransport = {
|
||||
|
||||
type NodeRunnerInventoryRecord = Omit<
|
||||
NodeWorkerSupervisorNodeProof,
|
||||
"commands" | "pairingGeneration" | "workerBuild" | "workerRuns"
|
||||
"commands" | "pairingGeneration" | "protocolFeature" | "workerBuild" | "workerRuns"
|
||||
> & {
|
||||
protocolFeatures: readonly string[];
|
||||
workerRuns?: WorkerAdmissionHandshake;
|
||||
@@ -215,7 +219,6 @@ function resolveWorkerSupervisorProof(
|
||||
declaration.pairingIdentity !== node.pairingIdentity ||
|
||||
declaration.clientId !== node.clientId ||
|
||||
declaration.clientMode !== node.clientMode ||
|
||||
declaration.protocolFeature !== NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE ||
|
||||
!declaration.protocolFeatures.includes(NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE) ||
|
||||
(declaration.workerRuns !== undefined &&
|
||||
!sameOptionalWorkerBuild(declaration.workerRuns, node.workerRuns))
|
||||
@@ -236,6 +239,23 @@ function resolveWorkerSupervisorProof(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNodeRunnerIssue(
|
||||
node: NodeRegistryPrivateSession,
|
||||
runnerInventoryByConn: ReadonlyMap<string, NodeRunnerInventoryRecord>,
|
||||
): NodeRunnerInventoryIssue | undefined {
|
||||
const declaration = runnerInventoryByConn.get(node.connId);
|
||||
return declaration &&
|
||||
node.client.invalidated !== true &&
|
||||
declaration.nodeId === node.nodeId &&
|
||||
declaration.pairingIdentity === node.pairingIdentity &&
|
||||
declaration.clientId === GATEWAY_CLIENT_IDS.NODE_HOST &&
|
||||
declaration.clientMode === "node" &&
|
||||
declaration.protocolFeatures.length === 1 &&
|
||||
declaration.protocolFeatures[0] === NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE
|
||||
? NODE_RUNNER_UPDATE_REQUIRED_ISSUE
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isWorkerSupervisorProofCurrent(
|
||||
state: NodeRegistryPrivateState,
|
||||
proof: NodeWorkerSupervisorNodeProof,
|
||||
@@ -253,7 +273,10 @@ function isWorkerSupervisorProofCurrent(
|
||||
current.clientMode === proof.clientMode &&
|
||||
current.protocolFeature === proof.protocolFeature &&
|
||||
sameOptionalWorkerBuild(current.workerBuild, proof.workerBuild) &&
|
||||
(!requireLaunchEligibility || sameOptionalWorkerBuild(current.workerRuns, proof.workerRuns))
|
||||
(!requireLaunchEligibility ||
|
||||
(current.workerRuns !== undefined &&
|
||||
proof.workerRuns !== undefined &&
|
||||
sameWorkerBuild(current.workerRuns, proof.workerRuns)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,9 +289,7 @@ function updateWorkerRunnerInventory(
|
||||
},
|
||||
): NodeRunnerInventoryUpdateResult | null {
|
||||
const node = state.context.getNode(params.nodeId);
|
||||
const publishesSupervisorDialect = params.declaration.protocolFeatures.some(
|
||||
(feature) => feature === NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
);
|
||||
const publishesRunnerDialect = params.declaration.protocolFeatures.length === 1;
|
||||
if (
|
||||
!node ||
|
||||
node.client.invalidated === true ||
|
||||
@@ -279,7 +300,7 @@ function updateWorkerRunnerInventory(
|
||||
return null;
|
||||
}
|
||||
const previous = state.runnerInventoryByConn.get(node.connId);
|
||||
if (!publishesSupervisorDialect) {
|
||||
if (!publishesRunnerDialect) {
|
||||
const changed = state.runnerInventoryByConn.delete(node.connId);
|
||||
if (changed) {
|
||||
state.context.publishActiveNodeContext();
|
||||
@@ -293,7 +314,6 @@ function updateWorkerRunnerInventory(
|
||||
pairingIdentity: node.pairingIdentity,
|
||||
clientId: GATEWAY_CLIENT_IDS.NODE_HOST,
|
||||
clientMode: "node",
|
||||
protocolFeature: NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
protocolFeatures: [...params.declaration.protocolFeatures],
|
||||
...(params.declaration.workerRuns
|
||||
? { workerRuns: structuredClone(params.declaration.workerRuns) }
|
||||
@@ -488,6 +508,10 @@ export function registerNodeRegistryPrivateRuntime(
|
||||
return proof ? [proof] : [];
|
||||
});
|
||||
},
|
||||
getIssue: (nodeId) => {
|
||||
const node = context.getNode(nodeId);
|
||||
return node ? resolveNodeRunnerIssue(node, state.runnerInventoryByConn) : undefined;
|
||||
},
|
||||
isCurrent: (node, requireLaunchEligibility = false) =>
|
||||
isWorkerSupervisorProofCurrent(state, node, requireLaunchEligibility),
|
||||
invoke: async (params) => {
|
||||
@@ -613,6 +637,18 @@ export function isNodeRunnerSessionHost(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeRunnerInventoryIssue(params: {
|
||||
registry: object;
|
||||
nodeId: string;
|
||||
connId: string;
|
||||
}): NodeRunnerInventoryIssue | undefined {
|
||||
const state = NODE_REGISTRY_PRIVATE_STATES.get(params.registry);
|
||||
const node = state?.context.getNode(params.nodeId);
|
||||
return state && node?.connId === params.connId
|
||||
? resolveNodeRunnerIssue(node, state.runnerInventoryByConn)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isNodeRegistryPendingInvokeConnectionActive(params: {
|
||||
registry: object;
|
||||
pending: PendingInvoke;
|
||||
|
||||
@@ -5,8 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { listNodePairing } from "../../infra/device-pairing-node.js";
|
||||
import { listDevicePairing } from "../../infra/device-pairing.js";
|
||||
import { NODE_RUNNER_UPDATE_REQUIRED_ISSUE } from "../../infra/node-runner-inventory.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../../shared/node-desktop-stream.js";
|
||||
import { isNodeRunnerSessionHost } from "../node-registry-private.js";
|
||||
import { getNodeRunnerInventoryIssue, isNodeRunnerSessionHost } from "../node-registry-private.js";
|
||||
import type { WorkerEnvironmentServiceRecord } from "../worker-environments/service-contract.js";
|
||||
import type { WorkerEnvironmentRecord } from "../worker-environments/store.js";
|
||||
import { environmentsHandlers, summarizeWorkerEnvironment } from "./environments.js";
|
||||
@@ -21,6 +22,7 @@ vi.mock("../../infra/device-pairing-node.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../node-registry-private.js", () => ({
|
||||
getNodeRunnerInventoryIssue: vi.fn(() => undefined),
|
||||
isNodeRunnerSessionHost: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
@@ -201,6 +203,7 @@ class FakeWorkerServiceError extends Error {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
||||
vi.mocked(isNodeRunnerSessionHost).mockReturnValue(false);
|
||||
vi.mocked(getNodeRunnerInventoryIssue).mockReturnValue(undefined);
|
||||
vi.mocked(listDevicePairing).mockResolvedValue({ paired: [] } as never);
|
||||
vi.mocked(listNodePairing).mockResolvedValue({
|
||||
paired: [
|
||||
@@ -307,6 +310,28 @@ describe("environment gateway methods", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects the same current-node update issue through list and status", async () => {
|
||||
vi.mocked(getNodeRunnerInventoryIssue).mockImplementation(({ nodeId }) =>
|
||||
nodeId === "node-live" ? NODE_RUNNER_UPDATE_REQUIRED_ISSUE : undefined,
|
||||
);
|
||||
|
||||
const [, listPayload] = await callEnvironmentMethod("environments.list", {});
|
||||
const [, statusPayload] = await callEnvironmentMethod("environments.status", {
|
||||
environmentId: "node:node-live",
|
||||
});
|
||||
const listed = (
|
||||
listPayload as { environments: Array<{ id: string; issues?: unknown[] }> }
|
||||
).environments.find((environment) => environment.id === "node:node-live");
|
||||
|
||||
expect(listed?.issues).toEqual([NODE_RUNNER_UPDATE_REQUIRED_ISSUE]);
|
||||
expect(statusPayload).toMatchObject({ issues: [NODE_RUNNER_UPDATE_REQUIRED_ISSUE] });
|
||||
expect(
|
||||
(
|
||||
listPayload as { environments: Array<{ id: string; issues?: unknown[] }> }
|
||||
).environments.find((environment) => environment.id === "gateway"),
|
||||
).not.toHaveProperty("issues");
|
||||
});
|
||||
|
||||
it("marks only connected, advertised, and explicitly allowed nodes as desktop sources", async () => {
|
||||
const context = mockContext();
|
||||
context.getRuntimeConfig = () =>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isDesktopCredentialsRequiredError } from "../desktop/host-source-errors
|
||||
import { getNodeDesktopService } from "../desktop/node-source-context.js";
|
||||
import { createKnownNodeCatalog, listKnownNodes } from "../node-catalog.js";
|
||||
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
||||
import { isNodeRunnerSessionHost } from "../node-registry-private.js";
|
||||
import { getNodeRunnerInventoryIssue, isNodeRunnerSessionHost } from "../node-registry-private.js";
|
||||
import type { WorkerEnvironmentServiceRecord } from "../worker-environments/service-contract.js";
|
||||
import type { WorkerEnvironmentState } from "../worker-environments/state.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
@@ -97,6 +97,7 @@ function summarizeNodeEnvironment(
|
||||
trust: "persistent",
|
||||
...(desktop ? { desktop: true } : {}),
|
||||
...(capabilities.length > 0 ? { capabilities } : {}),
|
||||
...(node.issues?.length ? { issues: [...node.issues] } : {}),
|
||||
};
|
||||
}
|
||||
/** Projects a durable worker row without exposing its SSH credential reference. */
|
||||
@@ -155,11 +156,22 @@ async function listEnvironments(context: GatewayRequestContext): Promise<Environ
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const issuesByNodeId = new Map(
|
||||
connectedNodes.flatMap((node) => {
|
||||
const issue = getNodeRunnerInventoryIssue({
|
||||
registry: context.nodeRegistry,
|
||||
nodeId: node.nodeId,
|
||||
connId: node.connId,
|
||||
});
|
||||
return issue ? [[node.nodeId, [issue]] as const] : [];
|
||||
}),
|
||||
);
|
||||
const catalog = createKnownNodeCatalog({
|
||||
pairedDevices: devices.paired,
|
||||
pairedNodes: nodes.paired,
|
||||
connectedNodes,
|
||||
sessionHostNodeIds,
|
||||
issuesByNodeId,
|
||||
});
|
||||
const config = context.getRuntimeConfig();
|
||||
const gateway =
|
||||
|
||||
@@ -2,6 +2,11 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PairedDevice } from "../../infra/device-pairing.js";
|
||||
import { resolveNodePairingState } from "../../infra/device-pairing.js";
|
||||
import {
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE,
|
||||
} from "../../infra/node-runner-inventory.js";
|
||||
import { createNodeRegistryRuntime, updateNodeRunnerInventory } from "../node-registry-private.js";
|
||||
import { NodeRegistry } from "../node-registry.js";
|
||||
import { nodeReadHandlers } from "./nodes.read.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
@@ -47,31 +52,30 @@ function createPairedNode(nodeId: string): PairedDevice {
|
||||
};
|
||||
}
|
||||
|
||||
function registerNode(registry: NodeRegistry, pairedNode: PairedDevice): void {
|
||||
function registerNode(registry: NodeRegistry, pairedNode: PairedDevice) {
|
||||
const pairingState = expectDefined(
|
||||
resolveNodePairingState(pairedNode),
|
||||
`${pairedNode.deviceId} pairing state`,
|
||||
);
|
||||
registry.register(
|
||||
{
|
||||
connId: `connection-${pairedNode.deviceId}`,
|
||||
connect: {
|
||||
client: {
|
||||
id: "node-host",
|
||||
version: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: "node",
|
||||
displayName: pairedNode.deviceId,
|
||||
},
|
||||
device: { id: pairedNode.deviceId },
|
||||
scopes: [],
|
||||
const client = {
|
||||
connId: `connection-${pairedNode.deviceId}`,
|
||||
connect: {
|
||||
client: {
|
||||
id: "node-host",
|
||||
version: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: "node",
|
||||
displayName: pairedNode.deviceId,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
pairingIdentity: pairingState.identity.key,
|
||||
...(pairingState.generation ? { pairingGeneration: pairingState.generation.key } : {}),
|
||||
device: { id: pairedNode.deviceId },
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
} as unknown as Parameters<NodeRegistry["register"]>[0];
|
||||
registry.register(client, {
|
||||
pairingIdentity: pairingState.identity.key,
|
||||
...(pairingState.generation ? { pairingGeneration: pairingState.generation.key } : {}),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("node read projections", () => {
|
||||
@@ -79,10 +83,23 @@ describe("node read projections", () => {
|
||||
const localNodeId = "local-node";
|
||||
const remoteNodeId = "remote-node";
|
||||
const pairedNodes = [createPairedNode(localNodeId), createPairedNode(remoteNodeId)];
|
||||
const nodeRegistry = new NodeRegistry();
|
||||
const runtime = createNodeRegistryRuntime(() => new NodeRegistry());
|
||||
const { nodeRegistry } = runtime;
|
||||
let remoteClient: ReturnType<typeof registerNode> | undefined;
|
||||
for (const pairedNode of pairedNodes) {
|
||||
registerNode(nodeRegistry, pairedNode);
|
||||
const client = registerNode(nodeRegistry, pairedNode);
|
||||
if (pairedNode.deviceId === remoteNodeId) {
|
||||
remoteClient = client;
|
||||
}
|
||||
}
|
||||
expect(
|
||||
updateNodeRunnerInventory({
|
||||
registry: nodeRegistry,
|
||||
nodeId: remoteNodeId,
|
||||
connId: remoteClient?.connId,
|
||||
declaration: { protocolFeatures: [NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE] },
|
||||
}),
|
||||
).toEqual({ changed: true });
|
||||
listDevicePairingMock.mockResolvedValue({ pending: [], paired: pairedNodes });
|
||||
resolveLocalNodeIdMock.mockResolvedValue(localNodeId);
|
||||
|
||||
@@ -112,7 +129,7 @@ describe("node read projections", () => {
|
||||
}
|
||||
|
||||
const list = (await request("node.list", {})) as {
|
||||
nodes: Array<{ nodeId: string; gatewayLocal?: boolean }>;
|
||||
nodes: Array<{ nodeId: string; gatewayLocal?: boolean; issues?: unknown[] }>;
|
||||
};
|
||||
expect(list.nodes.filter((node) => node.gatewayLocal)).toEqual([
|
||||
expect.objectContaining({ nodeId: localNodeId, gatewayLocal: true }),
|
||||
@@ -120,12 +137,16 @@ describe("node read projections", () => {
|
||||
expect(list.nodes.find((node) => node.nodeId === remoteNodeId)).not.toHaveProperty(
|
||||
"gatewayLocal",
|
||||
);
|
||||
expect(list.nodes.find((node) => node.nodeId === localNodeId)).not.toHaveProperty("issues");
|
||||
expect(list.nodes.find((node) => node.nodeId === remoteNodeId)?.issues).toEqual([
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
]);
|
||||
|
||||
await expect(request("node.describe", { nodeId: localNodeId })).resolves.toEqual(
|
||||
expect.objectContaining({ nodeId: localNodeId, gatewayLocal: true }),
|
||||
);
|
||||
await expect(request("node.describe", { nodeId: remoteNodeId })).resolves.not.toHaveProperty(
|
||||
"gatewayLocal",
|
||||
);
|
||||
await expect(request("node.describe", { nodeId: remoteNodeId })).resolves.toMatchObject({
|
||||
issues: [NODE_RUNNER_UPDATE_REQUIRED_ISSUE],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,22 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { projectNodePairing } from "../../infra/device-pairing-node.js";
|
||||
import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { parseNodeRunnerInventoryDeclaration } from "../../infra/node-runner-inventory.js";
|
||||
import {
|
||||
formatNodeRunnerUpdateRequired,
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE,
|
||||
parseNodeRunnerInventoryDeclaration,
|
||||
} from "../../infra/node-runner-inventory.js";
|
||||
import { resolveLocalNodeId } from "../../node-host/local-id.js";
|
||||
import type { NodeListNode } from "../../shared/node-list-types.js";
|
||||
import { replaceRemoteNodeSkills } from "../../skills/runtime/remote-skills.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../skills/runtime/remote.js";
|
||||
import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js";
|
||||
import { isNodeRunnerSessionHost, updateNodeRunnerInventory } from "../node-registry-private.js";
|
||||
import {
|
||||
getNodeRunnerInventoryIssue,
|
||||
isNodeRunnerSessionHost,
|
||||
updateNodeRunnerInventory,
|
||||
} from "../node-registry-private.js";
|
||||
import type { NodeSession } from "../node-registry.js";
|
||||
import {
|
||||
hasAuthorizedClientPluginNodeCapabilityUrl,
|
||||
@@ -89,12 +98,23 @@ async function listNodesForClient(params: {
|
||||
connectedNodes: params.connectedNodes,
|
||||
nodeRegistry: params.context.nodeRegistry,
|
||||
});
|
||||
const issuesByNodeId = new Map(
|
||||
params.connectedNodes.flatMap((node) => {
|
||||
const issue = getNodeRunnerInventoryIssue({
|
||||
registry: params.context.nodeRegistry,
|
||||
nodeId: node.nodeId,
|
||||
connId: node.connId,
|
||||
});
|
||||
return issue ? [[node.nodeId, [issue]] as const] : [];
|
||||
}),
|
||||
);
|
||||
const catalog = createKnownNodeCatalog({
|
||||
pairedDevices: params.pairedDevices,
|
||||
pairedNodes: params.pairedNodes,
|
||||
pendingNodes: params.pendingNodes,
|
||||
connectedNodes: params.connectedNodes,
|
||||
sessionHostNodeIds,
|
||||
issuesByNodeId,
|
||||
});
|
||||
const localNodeId = await resolveLocalNodeId().catch((error: unknown) => {
|
||||
params.context.logGateway.warn(
|
||||
@@ -403,6 +423,17 @@ export const nodeReadHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
|
||||
return;
|
||||
}
|
||||
if (declaration.protocolFeatures[0] === NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
formatNodeRunnerUpdateRequired(nodeId, NODE_RUNNER_UPDATE_REQUIRED_ISSUE),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { nodeId }, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WORKER_PROTOCOL_FEATURES } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import { NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE } from "../../infra/node-runner-inventory.js";
|
||||
import { NODE_WORKER_SUPERVISOR_STATUS_COMMAND } from "../../infra/node-commands.js";
|
||||
import {
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE,
|
||||
NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
} from "../../infra/node-runner-inventory.js";
|
||||
import {
|
||||
createNodeRegistryRuntime,
|
||||
setNodeRunnerInventoryChangedListener,
|
||||
@@ -168,6 +173,85 @@ describe("nodeHandlers node.runnerInventory.update", () => {
|
||||
runtime.nodeRegistry.unregister("conn-1");
|
||||
});
|
||||
|
||||
it("keeps exact v1 inventory diagnostic-only until disconnect and v2 reconnect", async () => {
|
||||
const inventoryChanged = vi.fn();
|
||||
const runtime = createNodeRegistryRuntime(() => new NodeRegistry());
|
||||
setNodeRunnerInventoryChangedListener(runtime.nodeRegistry, inventoryChanged);
|
||||
const legacyClient = createWorkerSupervisorNodeClient("conn-v1", WORKER_RUNS);
|
||||
runtime.nodeRegistry.register(legacyClient, {
|
||||
pairingIdentity: "identity-1",
|
||||
pairingGeneration: "generation-1",
|
||||
});
|
||||
const legacy = runnerInventoryOptions({
|
||||
nodeRegistry: runtime.nodeRegistry,
|
||||
client: legacyClient,
|
||||
declaration: {
|
||||
protocolFeatures: [NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE],
|
||||
workerRuns: WORKER_RUNS,
|
||||
},
|
||||
});
|
||||
|
||||
await runnerInventoryHandler(legacy);
|
||||
|
||||
expect(legacy.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("openclaw update"),
|
||||
}),
|
||||
);
|
||||
expect(inventoryChanged).toHaveBeenLastCalledWith("node-1");
|
||||
expect(runtime.nodeWorkerSupervisorTransport.getIssue?.("node-1")).toEqual(
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
);
|
||||
await expect(runtime.nodeWorkerSupervisorTransport.listCurrentNodes()).resolves.toEqual([]);
|
||||
const forgedProof = {
|
||||
nodeId: "node-1",
|
||||
connId: "conn-v1",
|
||||
pairingIdentity: "identity-1",
|
||||
pairingGeneration: "generation-1",
|
||||
clientId: "node-host",
|
||||
clientMode: "node",
|
||||
protocolFeature: NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
workerRuns: WORKER_RUNS,
|
||||
commands: ["system.run"],
|
||||
} as const;
|
||||
expect(runtime.nodeWorkerSupervisorTransport.isCurrent(forgedProof)).toBe(false);
|
||||
await expect(
|
||||
runtime.nodeWorkerSupervisorTransport.invoke({
|
||||
node: forgedProof,
|
||||
command: NODE_WORKER_SUPERVISOR_STATUS_COMMAND,
|
||||
isDispatchAuthorized: () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "PRIVATE_DIALECT_UNAVAILABLE" } });
|
||||
|
||||
runtime.nodeRegistry.unregister("conn-v1");
|
||||
expect(runtime.nodeWorkerSupervisorTransport.getIssue?.("node-1")).toBeUndefined();
|
||||
expect(inventoryChanged).toHaveBeenCalledTimes(2);
|
||||
|
||||
const currentClient = createWorkerSupervisorNodeClient("conn-v2", WORKER_RUNS);
|
||||
runtime.nodeRegistry.register(currentClient, {
|
||||
pairingIdentity: "identity-1",
|
||||
pairingGeneration: "generation-1",
|
||||
});
|
||||
await runnerInventoryHandler(
|
||||
runnerInventoryOptions({
|
||||
nodeRegistry: runtime.nodeRegistry,
|
||||
client: currentClient,
|
||||
declaration: {
|
||||
protocolFeatures: [NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE],
|
||||
workerRuns: WORKER_RUNS,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(runtime.nodeWorkerSupervisorTransport.getIssue?.("node-1")).toBeUndefined();
|
||||
await expect(runtime.nodeWorkerSupervisorTransport.listCurrentNodes()).resolves.toEqual([
|
||||
expect.objectContaining({ nodeId: "node-1", connId: "conn-v2", workerRuns: WORKER_RUNS }),
|
||||
]);
|
||||
runtime.nodeRegistry.unregister("conn-v2");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "missing list", params: {} },
|
||||
{ name: "extra key", params: { protocolFeatures: [], extra: true } },
|
||||
@@ -181,7 +265,7 @@ describe("nodeHandlers node.runnerInventory.update", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ name: "wrong dialect", params: { protocolFeatures: ["node-worker-supervisor-v2"] } },
|
||||
{ name: "wrong dialect", params: { protocolFeatures: ["node-worker-supervisor-v0"] } },
|
||||
{
|
||||
name: "worker build without dialect",
|
||||
params: { protocolFeatures: [], workerRuns: WORKER_RUNS },
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("gateway worker environment startup", () => {
|
||||
bundleHash: "a".repeat(64),
|
||||
openclawVersion: "2026.8.14",
|
||||
protocolFeatures: ["worker-heartbeat-v1"],
|
||||
installKind: "local",
|
||||
installKind: "bundle",
|
||||
},
|
||||
credential: {
|
||||
credentialHash: hashWorkerCredential("device-credential"),
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
DEVICE_WORKER_PROVIDER_ID,
|
||||
} from "./worker-environments/device-provider.js";
|
||||
import type { WorkerLiveEventReceiver } from "./worker-environments/live-events.js";
|
||||
import type { GatewayNodeWorkerBundleInstaller } from "./worker-environments/node-worker-bundle-installer.js";
|
||||
import type { NodeWorkerBundleTransferHttpCallback } from "./worker-environments/node-worker-bundle-transfer-http.js";
|
||||
import { nodeWorkerGatewayNamespace as resolveNodeWorkerGatewayNamespace } from "./worker-environments/node-worker-gateway-namespace.js";
|
||||
import type { NodeWorkerWorkspaceBindingResolver } from "./worker-environments/node-worker-tunnel.js";
|
||||
@@ -53,7 +52,6 @@ export type GatewayWorkerEnvironmentRuntime = {
|
||||
workerLiveEvents?: WorkerLiveEventReceiver;
|
||||
workerTunnelManager?: WorkerTunnelManager;
|
||||
nodeWorkerGatewayNamespace?: string;
|
||||
ensureNodeWorkerBundle?: GatewayNodeWorkerBundleInstaller;
|
||||
bindWorkerSessionDispatch?: (dispatch: WorkerPlacementDispatchContract["dispatch"]) => void;
|
||||
bindDeviceNodeControl?: (transport: NodeWorkerSupervisorTransport) => void;
|
||||
bindNodeWorkspaceBindingResolver?: (resolver: NodeWorkerWorkspaceBindingResolver) => void;
|
||||
@@ -244,10 +242,7 @@ export async function createGatewayWorkerEnvironmentRuntime(params: {
|
||||
? deviceRuntime.provider
|
||||
: resolveWorkerProvider(params.getPluginRegistry(), providerId),
|
||||
prepareInstallation,
|
||||
resolveNodeWorkerBuild: async (deviceId) => {
|
||||
const build = await deviceRuntime.resolveWorkerBuild(deviceId);
|
||||
return build ? structuredClone(build) : undefined;
|
||||
},
|
||||
ensureNodeWorkerBundle: async (deviceId) => await ensureNodeWorkerBundle({ deviceId }),
|
||||
tunnelManager: workerTunnelManager,
|
||||
nodeTunnelManager: nodeWorkerTunnelManager,
|
||||
stopNodeWorkerBundleTransfers: () => nodeWorkerBundleTransfer.closeAll(),
|
||||
@@ -299,7 +294,7 @@ export async function createGatewayWorkerEnvironmentRuntime(params: {
|
||||
logger: workerEnvironmentLog,
|
||||
});
|
||||
const workerEnvironmentService = workerEnvironmentServiceBase;
|
||||
bindDeviceWorkerAvailability(workerEnvironmentService, deviceRuntime.isAvailable);
|
||||
bindDeviceWorkerAvailability(workerEnvironmentService, deviceRuntime.resolveAvailability);
|
||||
bindDeviceWorkerReconciliation(workerEnvironmentService, async (deviceId) => {
|
||||
const environmentIds = params.startup.store
|
||||
.listForReconcile()
|
||||
@@ -337,7 +332,6 @@ export async function createGatewayWorkerEnvironmentRuntime(params: {
|
||||
workerLiveEvents,
|
||||
workerTunnelManager,
|
||||
nodeWorkerGatewayNamespace,
|
||||
ensureNodeWorkerBundle,
|
||||
bindWorkerSessionDispatch: (dispatch) => {
|
||||
dispatchChild = dispatch;
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getFallbackGatewayContext } from "./server-plugin-fallback-context.js";
|
||||
import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
import { installGatewayTestHooks, rpcReq } from "./test-helpers.js";
|
||||
import { isDeviceWorkerAvailable } from "./worker-environments/device-provider.js";
|
||||
import { resolveDeviceWorkerAvailability } from "./worker-environments/device-provider.js";
|
||||
import type { WorkerSessionPlacementStore } from "./worker-environments/placement-store.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
@@ -28,8 +28,8 @@ test(
|
||||
const context = getFallbackGatewayContext();
|
||||
expect(context?.workerEnvironmentService).toBeDefined();
|
||||
await expect(
|
||||
isDeviceWorkerAvailable(context?.workerEnvironmentService, "missing-device"),
|
||||
).resolves.toBe(false);
|
||||
resolveDeviceWorkerAvailability(context?.workerEnvironmentService, "missing-device"),
|
||||
).resolves.toEqual({ available: false });
|
||||
const placements = context?.workerSessionPlacementService as
|
||||
| WorkerSessionPlacementStore
|
||||
| undefined;
|
||||
|
||||
@@ -19,13 +19,6 @@ import type { WorkerEnvironmentStore } from "./store.js";
|
||||
export type { WorkerConnectionIdentity } from "./connection-identity.js";
|
||||
export type { ExpectedWorkerBuild } from "../../worker/worker-build-identity.js";
|
||||
|
||||
/** Local-install receipts pin the node's paired-machine claim instead of Gateway bundle bytes. */
|
||||
export function resolveLocalWorkerBuild(
|
||||
receipt: (WorkerAdmissionHandshake & { installKind?: "bundle" | "local" }) | null | undefined,
|
||||
): ExpectedWorkerBuild | undefined {
|
||||
return receipt?.installKind === "local" ? receipt : undefined;
|
||||
}
|
||||
|
||||
/** True only for bundles that accept the exact admitted execution carrier. */
|
||||
export function supportsWorkerExecutionContextLaunch(
|
||||
handshake: Pick<WorkerAdmissionHandshake, "protocolFeatures"> | null | undefined,
|
||||
|
||||
@@ -188,6 +188,8 @@ describe("bootstrapWorker", () => {
|
||||
expect(runner.calls[2]?.options.input).toContain("lock=$lock_root/$hash");
|
||||
expect(runner.calls[2]?.options.input).toContain('ln -s "$lock_identity" "$lock"');
|
||||
expect(runner.calls[2]?.options.input).toContain("worker bundle archive digest mismatch");
|
||||
expect(runner.calls[2]?.options.input).toContain('addFile("worker.mjs")');
|
||||
expect(runner.calls[2]?.options.input).not.toContain('npm install --prefix "$staging"');
|
||||
expect(runner.calls[2]?.options.input).toContain("worker install content does not match");
|
||||
expect(runner.calls[2]?.options.input).toContain(
|
||||
'mv "$staging" "$install_dir"\nfinish_with_receipt',
|
||||
@@ -412,11 +414,10 @@ describe("bootstrapWorker", () => {
|
||||
|
||||
expect(npmRunner.calls.map((call) => call.argv[0])).toEqual(["ssh", "ssh", "ssh"]);
|
||||
expect(npmRunner.calls[1]?.options.input).toContain("npm pack");
|
||||
expect(npmRunner.calls[1]?.options.input).toContain("npm install --global");
|
||||
expect(npmRunner.calls[1]?.options.input).not.toContain("npm install");
|
||||
expect(npmRunner.calls[1]?.options.input).toContain("--registry=https://registry.npmjs.org/");
|
||||
expect(npmRunner.calls[1]?.options.input).toContain("postinstall-inventory.json");
|
||||
expect(npmRunner.calls[1]?.options.input).toContain("lib/node_modules/openclaw");
|
||||
expect(npmRunner.calls[1]?.options.input).toContain('cp -R "$package_dir/." "$staging/"');
|
||||
expect(npmRunner.calls[1]?.options.input).toContain("package/dist/worker/worker.mjs");
|
||||
expect(npmRunner.calls[1]?.options.input).not.toContain("node_modules");
|
||||
expect(npmRunner.calls[1]?.argv.at(-1)).toContain(`openclaw@${VERSION}`);
|
||||
});
|
||||
|
||||
@@ -602,15 +603,14 @@ describe("bootstrapWorker", () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bootstrap-script-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const remoteHome = path.join(root, "remote-home");
|
||||
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||
await fs.mkdir(path.join(packageRoot, "dist", "worker"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw", version: VERSION, files: ["dist/"] })}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(packageRoot, "openclaw.mjs"), "import './dist/entry.js';\n", {
|
||||
await fs.writeFile(path.join(packageRoot, "dist/worker/worker.mjs"), "export {};\n", {
|
||||
mode: 0o755,
|
||||
});
|
||||
await fs.writeFile(path.join(packageRoot, "dist/entry.js"), "export {};\n");
|
||||
const artifact = await createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
@@ -722,6 +722,20 @@ describe("bootstrapWorker", () => {
|
||||
bootstrapWorker(bootstrapRequest, { resolveIdentity, runCommand }),
|
||||
).resolves.toEqual(JSON.parse(receiptJson));
|
||||
|
||||
const tamperedDependency = path.join(
|
||||
remoteHome,
|
||||
".openclaw-worker",
|
||||
artifact.bundleHash,
|
||||
"node_modules",
|
||||
"tampered.js",
|
||||
);
|
||||
await fs.mkdir(path.dirname(tamperedDependency), { recursive: true });
|
||||
await fs.writeFile(tamperedDependency, "export const trusted = false;\n");
|
||||
await expect(
|
||||
bootstrapWorker(bootstrapRequest, { resolveIdentity, runCommand }),
|
||||
).resolves.toEqual(JSON.parse(receiptJson));
|
||||
await expect(fs.stat(tamperedDependency)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const operationUpload = preflightPaths[0]!;
|
||||
const staleUpload = path.join(
|
||||
path.dirname(operationUpload),
|
||||
@@ -737,13 +751,13 @@ describe("bootstrapWorker", () => {
|
||||
).resolves.toEqual(JSON.parse(receiptJson));
|
||||
expect(cleanupAttempts).toBe(cleanupAttemptsBeforeCurrent);
|
||||
|
||||
expect(transfers).toBe(1);
|
||||
expect(preflightPaths).toHaveLength(4);
|
||||
expect(transfers).toBe(2);
|
||||
expect(preflightPaths).toHaveLength(5);
|
||||
expect(new Set(preflightPaths).size).toBe(1);
|
||||
expect(path.basename(preflightPaths[0]!)).toBe(
|
||||
`openclaw-upload-${artifact.bundleHash}.tgz.${OPERATION_TOKEN}`,
|
||||
);
|
||||
expect(installAttempts).toBe(2);
|
||||
expect(installAttempts).toBe(3);
|
||||
expect(uploadSurvivedAmbiguousInstall).toBe(true);
|
||||
await expect(fs.stat(remoteTarball)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(operationUpload)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -830,25 +844,19 @@ describe("bootstrapWorker", () => {
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"verifies npm installs from the packaged dist inventory",
|
||||
"verifies npm installs from the dedicated worker artifact",
|
||||
async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bootstrap-npm-inventory-" }, async (root) => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bootstrap-npm-artifact-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const remoteHome = path.join(root, "remote-home");
|
||||
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||
await fs.mkdir(path.join(packageRoot, "dist", "worker"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw", version: VERSION, files: ["dist/"] })}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(packageRoot, "openclaw.mjs"), "import './dist/entry.js';\n", {
|
||||
await fs.writeFile(path.join(packageRoot, "dist/worker/worker.mjs"), "export {};\n", {
|
||||
mode: 0o755,
|
||||
});
|
||||
await fs.writeFile(path.join(packageRoot, "dist/entry.js"), "export {};\n");
|
||||
await fs.writeFile(path.join(packageRoot, "dist/entry.js.map"), "excluded map\n");
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist/postinstall-inventory.json"),
|
||||
`${JSON.stringify(["dist/entry.js"])}\n`,
|
||||
);
|
||||
const bundle = await createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
@@ -868,8 +876,12 @@ describe("bootstrapWorker", () => {
|
||||
protocolFeatures: [],
|
||||
});
|
||||
const installRoot = path.join(remoteHome, ".openclaw-worker", bundle.bundleHash);
|
||||
await fs.mkdir(path.dirname(installRoot), { recursive: true });
|
||||
await fs.cp(packageRoot, installRoot, { recursive: true });
|
||||
await fs.mkdir(installRoot, { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(packageRoot, "dist", "worker", "worker.mjs"),
|
||||
path.join(installRoot, "worker.mjs"),
|
||||
);
|
||||
await fs.chmod(path.join(installRoot, "worker.mjs"), 0o700);
|
||||
await fs.writeFile(path.join(installRoot, "bootstrap-receipt.json"), `${receiptJson}\n`);
|
||||
const runCommand: WorkerBootstrapCommandRunner = async (_argv, options) => {
|
||||
const isPreflight =
|
||||
|
||||
@@ -170,7 +170,7 @@ function addFile(relative) {
|
||||
fail("unsafe worker file: " + relative);
|
||||
}
|
||||
const contents = fs.readFileSync(absolute);
|
||||
const mode = relative === "openclaw.mjs" || (stats.mode & 0o111) !== 0 ? 0o700 : 0o600;
|
||||
const mode = relative === "worker.mjs" || (stats.mode & 0o111) !== 0 ? 0o700 : 0o600;
|
||||
fs.chmodSync(absolute, mode);
|
||||
entries.push({
|
||||
path: relative,
|
||||
@@ -179,73 +179,18 @@ function addFile(relative) {
|
||||
sha256: crypto.createHash("sha256").update(contents).digest("hex"),
|
||||
});
|
||||
}
|
||||
function walk(relativeDirectory) {
|
||||
assertDirectory(relativeDirectory);
|
||||
const absoluteDirectory = path.join(root, ...relativeDirectory.split("/"));
|
||||
for (const name of fs.readdirSync(absoluteDirectory).sort()) {
|
||||
const relative = relativeDirectory + "/" + name;
|
||||
const stats = fs.lstatSync(path.join(root, ...relative.split("/")));
|
||||
if (stats.isSymbolicLink()) {
|
||||
fail("unsafe worker path: " + relative);
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
walk(relative);
|
||||
} else {
|
||||
addFile(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
function readNpmInventory() {
|
||||
assertDirectory("dist");
|
||||
const inventoryPath = path.join(root, "dist", "postinstall-inventory.json");
|
||||
const inventoryStats = fs.lstatSync(inventoryPath);
|
||||
if (inventoryStats.isSymbolicLink() || !inventoryStats.isFile()) {
|
||||
fail("unsafe worker dist inventory");
|
||||
}
|
||||
const value = JSON.parse(fs.readFileSync(inventoryPath, "utf8"));
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
||||
fail("invalid worker dist inventory");
|
||||
}
|
||||
const unique = new Set(value);
|
||||
if (unique.size !== value.length) {
|
||||
fail("duplicate worker dist inventory entry");
|
||||
}
|
||||
for (const relative of value) {
|
||||
if (
|
||||
!relative.startsWith("dist/") ||
|
||||
relative.includes("\\") ||
|
||||
path.posix.normalize(relative) !== relative ||
|
||||
relative === "dist/postinstall-inventory.json"
|
||||
) {
|
||||
fail("unsafe worker dist inventory entry: " + relative);
|
||||
}
|
||||
addFile(relative);
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertRoot();
|
||||
addFile("openclaw.mjs");
|
||||
addFile("package.json");
|
||||
if (install === "npm") {
|
||||
readNpmInventory();
|
||||
} else if (install === "bundle") {
|
||||
walk("dist");
|
||||
// Vendored workspace packages ship inside the bundle and are part of its hash;
|
||||
// node_modules is installed after verification and never walked here.
|
||||
const vendorPath = path.join(root, "vendor");
|
||||
const vendorStats = fs.existsSync(vendorPath) ? fs.lstatSync(vendorPath) : undefined;
|
||||
if (vendorStats) {
|
||||
if (vendorStats.isSymbolicLink() || !vendorStats.isDirectory()) {
|
||||
fail("unsafe worker vendor directory");
|
||||
if (install === "npm" || install === "bundle") {
|
||||
for (const name of fs.readdirSync(root)) {
|
||||
if (name !== "worker.mjs" && name !== "bootstrap-receipt.json") {
|
||||
fail("unexpected worker bundle path: " + name);
|
||||
}
|
||||
walk("vendor");
|
||||
}
|
||||
addFile("worker.mjs");
|
||||
} else {
|
||||
fail("invalid worker install channel");
|
||||
}
|
||||
if (entries.length < 3) {
|
||||
fail("worker dist is empty");
|
||||
}
|
||||
entries.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
||||
const separator = String.fromCharCode(0);
|
||||
const hash = crypto.createHash("sha256");
|
||||
@@ -495,7 +440,6 @@ case "$install" in
|
||||
printf '%s\n' '${NPM_MISSING_MARKER}' >&2
|
||||
exit ${NPM_MISSING_EXIT_CODE}
|
||||
fi
|
||||
npm_prefix=$staging/.npm-prefix
|
||||
npm_pack_json=$staging/npm-pack.json
|
||||
npm pack "$package_spec" --pack-destination "$staging" --ignore-scripts --json --registry=https://registry.npmjs.org/ > "$npm_pack_json"
|
||||
package_archive=$(node -e '${READ_NPM_PACK_FILENAME_JS}' "$npm_pack_json")
|
||||
@@ -504,15 +448,7 @@ case "$install" in
|
||||
printf '%s\n' 'worker npm package integrity mismatch' >&2
|
||||
exit 2
|
||||
fi
|
||||
npm install --global --prefix "$npm_prefix" --ignore-scripts --omit=dev --no-audit --no-fund "$package_archive"
|
||||
package_dir=$npm_prefix/lib/node_modules/openclaw
|
||||
if [ ! -f "$package_dir/openclaw.mjs" ]; then
|
||||
printf '%s\n' 'npm did not install the OpenClaw package root' >&2
|
||||
exit 2
|
||||
fi
|
||||
# Match bundle layout so the worker entry always lives under the versioned root.
|
||||
cp -R "$package_dir/." "$staging/"
|
||||
rm -rf "$npm_prefix"
|
||||
tar -xzf "$package_archive" -C "$staging" --strip-components=3 package/dist/worker/worker.mjs
|
||||
rm -f "$npm_pack_json" "$package_archive"
|
||||
;;
|
||||
*)
|
||||
@@ -525,15 +461,6 @@ if ! node -e '${VERIFY_INSTALL_JS}' "$staging" "$hash" "$install"; then
|
||||
printf '%s\n' 'worker install content does not match the expected bundle hash' >&2
|
||||
exit 2
|
||||
fi
|
||||
# Materialize production dependencies only after the pristine bundle passed its
|
||||
# integrity check; npm install writes node_modules the hash intentionally excludes.
|
||||
if [ "$install" = bundle ]; then
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
printf '%s\n' '${NPM_MISSING_MARKER}' >&2
|
||||
exit ${NPM_MISSING_EXIT_CODE}
|
||||
fi
|
||||
npm install --prefix "$staging" --ignore-scripts --omit=dev --no-audit --no-fund >&2
|
||||
fi
|
||||
printf '%s\n' "$receipt_json" > "$staging/${BOOTSTRAP_RECEIPT}"
|
||||
chmod 600 "$staging/${BOOTSTRAP_RECEIPT}"
|
||||
rm -rf "$install_dir"
|
||||
|
||||
@@ -2,14 +2,9 @@ import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants, type BigIntStats } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { collectPackageDistInventory } from "../../infra/package-dist-inventory.js";
|
||||
import { WORKER_BUNDLE_ENTRY_PATH } from "../../shared/worker-bundle-hash.js";
|
||||
|
||||
// Host node_modules can contain platform-native code and is not portable to a leased box.
|
||||
// The bundle ships dist, a pruned package.json, and vendored copies of dist-external
|
||||
// workspace packages; bootstrap installs production dependencies on the box with
|
||||
// scripts disabled, mirroring the npm channel.
|
||||
const WORKER_PACKAGE_LIFECYCLE_FIELDS = ["devDependencies", "scripts", "pnpm"] as const;
|
||||
const CONTROL_UI_DIST_PREFIX = "dist/control-ui/";
|
||||
const WORKER_DEPLOY_ARTIFACT_PATH = "dist/worker/worker.mjs";
|
||||
|
||||
export type WorkerBundleManifestEntry = {
|
||||
path: string;
|
||||
@@ -30,35 +25,6 @@ export type WorkerBundleSourceIdentityEntry = {
|
||||
ctimeNs: bigint;
|
||||
};
|
||||
|
||||
type WorkerBundleSourceIdentityMap = Map<string, WorkerBundleSourceIdentityEntry>;
|
||||
|
||||
function recordSourceIdentity(
|
||||
identities: WorkerBundleSourceIdentityMap | undefined,
|
||||
entry: WorkerBundleSourceIdentityEntry,
|
||||
): void {
|
||||
identities?.set(`${entry.kind}\0${entry.path}`, entry);
|
||||
}
|
||||
|
||||
async function recordSourceDirectoryIdentity(
|
||||
identities: WorkerBundleSourceIdentityMap | undefined,
|
||||
directoryPath: string,
|
||||
): Promise<void> {
|
||||
if (!identities) {
|
||||
return;
|
||||
}
|
||||
const realPath = await fs.realpath(directoryPath);
|
||||
const stats = await fs.lstat(realPath, { bigint: true });
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe worker bundle directory: ${directoryPath}`);
|
||||
}
|
||||
recordSourceIdentity(identities, {
|
||||
path: realPath,
|
||||
realPath,
|
||||
kind: "directory",
|
||||
...sourceIdentityStats(stats),
|
||||
});
|
||||
}
|
||||
|
||||
function sourceIdentityStats(stats: BigIntStats) {
|
||||
return {
|
||||
dev: stats.dev,
|
||||
@@ -70,93 +36,36 @@ function sourceIdentityStats(stats: BigIntStats) {
|
||||
};
|
||||
}
|
||||
|
||||
export function comparePaths(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function readManifestDependencies(parsed: Record<string, unknown>): Record<string, string> {
|
||||
return parsed.dependencies && typeof parsed.dependencies === "object"
|
||||
? (parsed.dependencies as Record<string, string>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function withoutLifecycleFields(parsed: Record<string, unknown>): {
|
||||
pruned: Record<string, unknown>;
|
||||
prunedFieldCount: number;
|
||||
} {
|
||||
const prunedFields = WORKER_PACKAGE_LIFECYCLE_FIELDS.filter((key) => key in parsed);
|
||||
const pruned = { ...parsed };
|
||||
for (const key of prunedFields) {
|
||||
delete pruned[key];
|
||||
async function stageWorkerDeployArtifact(params: {
|
||||
sourceRoot: string;
|
||||
stagingRoot: string;
|
||||
}): Promise<{
|
||||
entry: WorkerBundleManifestEntry;
|
||||
sourceIdentity: WorkerBundleSourceIdentityEntry;
|
||||
}> {
|
||||
const sourcePath = path.join(params.sourceRoot, WORKER_DEPLOY_ARTIFACT_PATH);
|
||||
let expectedRealPath: string;
|
||||
try {
|
||||
expectedRealPath = await fs.realpath(sourcePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`OpenClaw worker deploy artifact is missing; build the running package at ${params.sourceRoot}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return { pruned, prunedFieldCount: prunedFields.length };
|
||||
}
|
||||
|
||||
function serializePackageManifest(parsed: Record<string, unknown>): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
// `workspace:` specs cannot resolve on the box: packages bundled into dist are dropped,
|
||||
// while dist-external workspace packages are rewritten to relative file: specs that point
|
||||
// at their vendored copies so `npm install` on the box resolves them without a registry.
|
||||
function pruneWorkerPackageManifest(
|
||||
contents: Buffer,
|
||||
vendoredDirsByName: ReadonlyMap<string, string> = new Map(),
|
||||
): Buffer {
|
||||
const parsed = JSON.parse(contents.toString("utf8")) as Record<string, unknown>;
|
||||
const dependencies = readManifestDependencies(parsed);
|
||||
let workspaceSpecCount = 0;
|
||||
const portable: Record<string, string> = {};
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
if (!spec.startsWith("workspace:")) {
|
||||
portable[name] = spec;
|
||||
continue;
|
||||
}
|
||||
workspaceSpecCount += 1;
|
||||
const vendorDir = vendoredDirsByName.get(name);
|
||||
if (vendorDir) {
|
||||
portable[name] = `file:./${vendorDir}`;
|
||||
}
|
||||
const expectedPath = path.resolve(params.sourceRoot, WORKER_DEPLOY_ARTIFACT_PATH);
|
||||
if (expectedRealPath !== expectedPath) {
|
||||
throw new Error(`Unsafe worker deploy artifact: ${WORKER_DEPLOY_ARTIFACT_PATH}`);
|
||||
}
|
||||
const { pruned, prunedFieldCount } = withoutLifecycleFields(parsed);
|
||||
if (prunedFieldCount === 0 && workspaceSpecCount === 0) {
|
||||
// Released package manifests are already portable; keep bytes (and hashes) stable.
|
||||
return contents;
|
||||
}
|
||||
pruned.dependencies = portable;
|
||||
return serializePackageManifest(pruned);
|
||||
}
|
||||
|
||||
function normalizePortableMode(mode: number, relativePath: string): number {
|
||||
return relativePath === "openclaw.mjs" || (mode & 0o111) !== 0 ? 0o700 : 0o600;
|
||||
}
|
||||
|
||||
type StagedFileSource = {
|
||||
sourcePath: string;
|
||||
expectedRealPath: string;
|
||||
stagedPath: string;
|
||||
transform?: (contents: Buffer) => Buffer;
|
||||
};
|
||||
|
||||
async function stageFileEntry(
|
||||
stagingRoot: string,
|
||||
source: StagedFileSource,
|
||||
sourceIdentities?: WorkerBundleSourceIdentityMap,
|
||||
): Promise<{ entry: WorkerBundleManifestEntry; contents: Buffer }> {
|
||||
const { sourcePath, expectedRealPath, stagedPath } = source;
|
||||
const sourceRealPath = await fs.realpath(sourcePath);
|
||||
if (sourceRealPath !== expectedRealPath) {
|
||||
throw new Error(`Unsafe worker bundle path: ${stagedPath}`);
|
||||
}
|
||||
const stats = await fs.lstat(sourcePath);
|
||||
if (stats.isSymbolicLink() || !stats.isFile()) {
|
||||
throw new Error(`Unsafe worker bundle path: ${stagedPath}`);
|
||||
const initialStats = await fs.lstat(sourcePath);
|
||||
if (initialStats.isSymbolicLink() || !initialStats.isFile()) {
|
||||
throw new Error(`Unsafe worker deploy artifact: ${WORKER_DEPLOY_ARTIFACT_PATH}`);
|
||||
}
|
||||
const handle = await fs.open(sourcePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
let contents: Buffer;
|
||||
let mode: number;
|
||||
let openedStats: BigIntStats;
|
||||
try {
|
||||
const openedStats = await handle.stat({ bigint: true });
|
||||
openedStats = await handle.stat({ bigint: true });
|
||||
const currentStats = await fs.lstat(sourcePath, { bigint: true });
|
||||
const currentRealPath = await fs.realpath(sourcePath);
|
||||
if (
|
||||
@@ -167,259 +76,39 @@ async function stageFileEntry(
|
||||
currentStats.dev !== openedStats.dev ||
|
||||
currentStats.ino !== openedStats.ino
|
||||
) {
|
||||
throw new Error(`Worker bundle path changed while packaging: ${stagedPath}`);
|
||||
throw new Error(
|
||||
`Worker deploy artifact changed while packaging: ${WORKER_DEPLOY_ARTIFACT_PATH}`,
|
||||
);
|
||||
}
|
||||
contents = await handle.readFile();
|
||||
if (source.transform) {
|
||||
contents = source.transform(contents);
|
||||
}
|
||||
mode = normalizePortableMode(Number(openedStats.mode), stagedPath);
|
||||
recordSourceIdentity(sourceIdentities, {
|
||||
path: expectedRealPath,
|
||||
realPath: currentRealPath,
|
||||
kind: "file",
|
||||
...sourceIdentityStats(openedStats),
|
||||
});
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const stagedFilePath = path.join(stagingRoot, ...stagedPath.split("/"));
|
||||
await fs.mkdir(path.dirname(stagedFilePath), { recursive: true });
|
||||
await fs.writeFile(stagedFilePath, contents, { mode });
|
||||
await fs.chmod(stagedFilePath, mode);
|
||||
const stagedPath = path.join(params.stagingRoot, WORKER_BUNDLE_ENTRY_PATH);
|
||||
await fs.writeFile(stagedPath, contents, { mode: 0o700 });
|
||||
await fs.chmod(stagedPath, 0o700);
|
||||
return {
|
||||
entry: {
|
||||
path: stagedPath,
|
||||
mode,
|
||||
path: WORKER_BUNDLE_ENTRY_PATH,
|
||||
mode: 0o700,
|
||||
size: contents.byteLength,
|
||||
sha256: createHash("sha256").update(contents).digest("hex"),
|
||||
},
|
||||
contents,
|
||||
};
|
||||
}
|
||||
|
||||
async function stageManifestEntry(
|
||||
sourceRoot: string,
|
||||
sourceRootRealPath: string,
|
||||
stagingRoot: string,
|
||||
relativePath: string,
|
||||
transform?: (contents: Buffer) => Buffer,
|
||||
sourceIdentities?: WorkerBundleSourceIdentityMap,
|
||||
): Promise<{ entry: WorkerBundleManifestEntry; contents: Buffer }> {
|
||||
return await stageFileEntry(
|
||||
stagingRoot,
|
||||
{
|
||||
sourcePath: path.join(sourceRoot, relativePath),
|
||||
expectedRealPath: path.resolve(sourceRootRealPath, ...relativePath.split("/")),
|
||||
stagedPath: relativePath,
|
||||
transform,
|
||||
sourceIdentity: {
|
||||
path: expectedRealPath,
|
||||
realPath: expectedRealPath,
|
||||
kind: "file",
|
||||
...sourceIdentityStats(openedStats),
|
||||
},
|
||||
sourceIdentities,
|
||||
);
|
||||
}
|
||||
|
||||
// tsdown keeps some @openclaw workspace packages external of dist (never-bundle list),
|
||||
// so shipped dist imports them at runtime; scan staged bytes for those specifiers to
|
||||
// know which workspace builds must ride along in the bundle.
|
||||
const OPENCLAW_IMPORT_SPECIFIER_PATTERN =
|
||||
/["'`](@openclaw\/[a-z0-9-]+)(?:\/[A-Za-z0-9./_-]+)?["'`]/gu;
|
||||
|
||||
function collectOpenclawImportSpecifiers(
|
||||
relativePath: string,
|
||||
contents: Buffer,
|
||||
into: Set<string>,
|
||||
): void {
|
||||
if (!/\.(?:cjs|js|mjs)$/u.test(relativePath)) {
|
||||
return;
|
||||
}
|
||||
for (const match of contents.toString("utf8").matchAll(OPENCLAW_IMPORT_SPECIFIER_PATTERN)) {
|
||||
const packageName = match[1];
|
||||
if (packageName) {
|
||||
into.add(packageName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneVendoredPackageManifest(
|
||||
packageName: string,
|
||||
referencedPackages: ReadonlySet<string>,
|
||||
contents: Buffer,
|
||||
): Buffer {
|
||||
const parsed = JSON.parse(contents.toString("utf8")) as Record<string, unknown>;
|
||||
for (const [dependencyName, spec] of Object.entries(readManifestDependencies(parsed))) {
|
||||
if (spec.startsWith("workspace:") && referencedPackages.has(dependencyName)) {
|
||||
throw new Error(
|
||||
`Vendored workspace dependency ${dependencyName} remains referenced by ${packageName} dist; bundle it into the package build or add explicit worker bundle support`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return pruneWorkerPackageManifest(contents);
|
||||
}
|
||||
|
||||
async function readWorkspaceDependencyNames(sourceRoot: string): Promise<Set<string>> {
|
||||
const raw = await fs.readFile(path.join(sourceRoot, "package.json"), "utf8");
|
||||
const dependencies = readManifestDependencies(JSON.parse(raw) as Record<string, unknown>);
|
||||
const names = Object.entries(dependencies)
|
||||
.filter(([, spec]) => spec.startsWith("workspace:"))
|
||||
.map(([name]) => name);
|
||||
return new Set(names);
|
||||
}
|
||||
|
||||
async function collectVendoredPackageFiles(
|
||||
packageName: string,
|
||||
vendorRealRoot: string,
|
||||
sourceIdentities?: WorkerBundleSourceIdentityMap,
|
||||
): Promise<string[]> {
|
||||
const files = ["package.json"];
|
||||
const walk = async (relativeDir: string): Promise<void> => {
|
||||
const directoryPath = path.join(vendorRealRoot, ...relativeDir.split("/"));
|
||||
await recordSourceDirectoryIdentity(sourceIdentities, directoryPath);
|
||||
const dirents = await fs.readdir(directoryPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const dirent of dirents) {
|
||||
const relativePath = `${relativeDir}/${dirent.name}`;
|
||||
if (dirent.isDirectory()) {
|
||||
await walk(relativePath);
|
||||
} else if (dirent.isFile()) {
|
||||
files.push(relativePath);
|
||||
} else {
|
||||
throw new Error(`Unsafe worker bundle vendor path: ${packageName}/${relativePath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
await walk("dist");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Workspace dependency ${packageName} referenced by the worker dist has no built dist directory at ${vendorRealRoot}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return files.toSorted(comparePaths);
|
||||
}
|
||||
|
||||
async function stageVendoredWorkspacePackages(params: {
|
||||
sourceRoot: string;
|
||||
stagingRoot: string;
|
||||
packageNames: readonly string[];
|
||||
sourceIdentities?: WorkerBundleSourceIdentityMap;
|
||||
}): Promise<{ entries: WorkerBundleManifestEntry[]; vendoredDirsByName: Map<string, string> }> {
|
||||
const entries: WorkerBundleManifestEntry[] = [];
|
||||
const vendoredDirsByName = new Map<string, string>();
|
||||
for (const packageName of [...params.packageNames].toSorted(comparePaths)) {
|
||||
const linkedPath = path.join(params.sourceRoot, "node_modules", ...packageName.split("/"));
|
||||
let vendorRealRoot: string;
|
||||
try {
|
||||
// pnpm links workspace packages into node_modules; realpath lands on the real
|
||||
// package dir, which is the staging source for its built dist.
|
||||
vendorRealRoot = await fs.realpath(linkedPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Worker bundle cannot resolve workspace dependency ${packageName} referenced by dist; expected an installed package at ${linkedPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const vendorDir = `vendor/${packageName.replace(/^@/u, "").replaceAll("/", "-")}`;
|
||||
const files = await collectVendoredPackageFiles(
|
||||
packageName,
|
||||
vendorRealRoot,
|
||||
params.sourceIdentities,
|
||||
);
|
||||
const referencedPackages = new Set<string>();
|
||||
for (const relativePath of files.filter((candidate) => candidate !== "package.json")) {
|
||||
const { entry, contents } = await stageFileEntry(
|
||||
params.stagingRoot,
|
||||
{
|
||||
sourcePath: path.join(vendorRealRoot, ...relativePath.split("/")),
|
||||
expectedRealPath: path.resolve(vendorRealRoot, ...relativePath.split("/")),
|
||||
stagedPath: `${vendorDir}/${relativePath}`,
|
||||
},
|
||||
params.sourceIdentities,
|
||||
);
|
||||
collectOpenclawImportSpecifiers(relativePath, contents, referencedPackages);
|
||||
entries.push(entry);
|
||||
}
|
||||
const { entry: packageManifestEntry } = await stageFileEntry(
|
||||
params.stagingRoot,
|
||||
{
|
||||
sourcePath: path.join(vendorRealRoot, "package.json"),
|
||||
expectedRealPath: path.resolve(vendorRealRoot, "package.json"),
|
||||
stagedPath: `${vendorDir}/package.json`,
|
||||
transform: (contents) =>
|
||||
pruneVendoredPackageManifest(packageName, referencedPackages, contents),
|
||||
},
|
||||
params.sourceIdentities,
|
||||
);
|
||||
entries.push(packageManifestEntry);
|
||||
vendoredDirsByName.set(packageName, vendorDir);
|
||||
}
|
||||
return { entries, vendoredDirsByName };
|
||||
}
|
||||
|
||||
async function collectWorkerBundleManifestInternal(
|
||||
sourceRoot: string,
|
||||
stagingRoot: string,
|
||||
sourceIdentities?: WorkerBundleSourceIdentityMap,
|
||||
): Promise<WorkerBundleManifestEntry[]> {
|
||||
const sourceRootRealPath = await fs.realpath(sourceRoot);
|
||||
// Control UI assets are built lazily after the Gateway starts and never execute on workers.
|
||||
// Excluding them keeps worker identity stable across that startup race.
|
||||
const distFiles = (
|
||||
await collectPackageDistInventory(sourceRoot, {
|
||||
onDirectory: async (directoryPath) =>
|
||||
await recordSourceDirectoryIdentity(sourceIdentities, directoryPath),
|
||||
})
|
||||
).filter((relativePath) => !relativePath.startsWith(CONTROL_UI_DIST_PREFIX));
|
||||
if (distFiles.length === 0) {
|
||||
throw new Error(
|
||||
`OpenClaw worker bundle has no packaged dist files; build the running package at ${sourceRoot}`,
|
||||
);
|
||||
}
|
||||
const referencedPackages = new Set<string>();
|
||||
const entries: WorkerBundleManifestEntry[] = [];
|
||||
for (const relativePath of ["openclaw.mjs", ...distFiles].toSorted(comparePaths)) {
|
||||
const { entry, contents } = await stageManifestEntry(
|
||||
sourceRoot,
|
||||
sourceRootRealPath,
|
||||
stagingRoot,
|
||||
relativePath,
|
||||
undefined,
|
||||
sourceIdentities,
|
||||
);
|
||||
collectOpenclawImportSpecifiers(relativePath, contents, referencedPackages);
|
||||
entries.push(entry);
|
||||
}
|
||||
const workspaceDependencyNames = await readWorkspaceDependencyNames(sourceRoot);
|
||||
const vendored = await stageVendoredWorkspacePackages({
|
||||
sourceRoot,
|
||||
stagingRoot,
|
||||
packageNames: [...workspaceDependencyNames].filter((name) => referencedPackages.has(name)),
|
||||
sourceIdentities,
|
||||
});
|
||||
entries.push(...vendored.entries);
|
||||
// The shipped root manifest is derived after the dist scan so vendored workspace deps
|
||||
// can be rewritten to their staged file: locations.
|
||||
const manifest = await stageManifestEntry(
|
||||
sourceRoot,
|
||||
sourceRootRealPath,
|
||||
stagingRoot,
|
||||
"package.json",
|
||||
(contents) => pruneWorkerPackageManifest(contents, vendored.vendoredDirsByName),
|
||||
sourceIdentities,
|
||||
);
|
||||
entries.push(manifest.entry);
|
||||
return entries.toSorted((left, right) => comparePaths(left.path, right.path));
|
||||
}
|
||||
|
||||
export async function collectWorkerBundleManifest(
|
||||
sourceRoot: string,
|
||||
stagingRoot: string,
|
||||
): Promise<WorkerBundleManifestEntry[]> {
|
||||
return await collectWorkerBundleManifestInternal(sourceRoot, stagingRoot);
|
||||
const staged = await stageWorkerDeployArtifact({ sourceRoot, stagingRoot });
|
||||
return [staged.entry];
|
||||
}
|
||||
|
||||
export async function collectWorkerBundleManifestWithSourceIdentity(
|
||||
@@ -429,12 +118,6 @@ export async function collectWorkerBundleManifestWithSourceIdentity(
|
||||
manifest: WorkerBundleManifestEntry[];
|
||||
sourceIdentity: WorkerBundleSourceIdentityEntry[];
|
||||
}> {
|
||||
const identities: WorkerBundleSourceIdentityMap = new Map();
|
||||
const manifest = await collectWorkerBundleManifestInternal(sourceRoot, stagingRoot, identities);
|
||||
return {
|
||||
manifest,
|
||||
sourceIdentity: [...identities.values()].toSorted((left, right) =>
|
||||
comparePaths(`${left.kind}\0${left.path}`, `${right.kind}\0${right.path}`),
|
||||
),
|
||||
};
|
||||
const staged = await stageWorkerDeployArtifact({ sourceRoot, stagingRoot });
|
||||
return { manifest: [staged.entry], sourceIdentity: [staged.sourceIdentity] };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveNodeWorkerInstallation } from "../../node-host/node-worker-build.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { withTestDir } from "../../test-helpers/temp-dir.js";
|
||||
import {
|
||||
createWorkerBundleProducer,
|
||||
@@ -12,28 +11,27 @@ import {
|
||||
} from "./bundle.js";
|
||||
|
||||
type WorkerBundleArtifact = Extract<WorkerInstallationArtifact, { install: "bundle" }>;
|
||||
const fixturePackageJson = `${JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
files: ["dist/"],
|
||||
})}\n`;
|
||||
|
||||
async function writeFixture(
|
||||
packageRoot: string,
|
||||
files: readonly (readonly [relativePath: string, contents: string])[],
|
||||
workerSource = "export const worker = true;\n",
|
||||
): Promise<void> {
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(packageRoot, "package.json"), fixturePackageJson, "utf8");
|
||||
await fs.writeFile(path.join(packageRoot, "openclaw.mjs"), "import './dist/entry.js';\n", {
|
||||
await fs.mkdir(path.join(packageRoot, "dist", "worker"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
dependencies: { json5: "2.2.3" },
|
||||
scripts: { postinstall: "node scripts/postinstall.mjs" },
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(packageRoot, "dist", "worker", "worker.mjs"), workerSource, {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
for (const [relativePath, contents] of files) {
|
||||
const filePath = path.join(packageRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function listTarball(tarballPath: string): Promise<string[]> {
|
||||
@@ -61,18 +59,28 @@ function bundleArtifact(overrides: Partial<WorkerBundleArtifact> = {}): WorkerBu
|
||||
}
|
||||
|
||||
describe("worker bundle producer", () => {
|
||||
it("hashes the same file manifest deterministically", async () => {
|
||||
it("hashes and archives only the dedicated deploy artifact", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-" }, async (root) => {
|
||||
const packageA = path.join(root, "package-a");
|
||||
const packageB = path.join(root, "package-b");
|
||||
const files = [
|
||||
["dist/entry.js", "export const entry = true;\n"],
|
||||
["dist/nested/worker.js", "export const worker = true;\n"],
|
||||
] as const;
|
||||
await writeFixture(packageA, files);
|
||||
await writeFixture(packageB, files.toReversed());
|
||||
await fs.utimes(path.join(packageA, "dist/entry.js"), new Date(1_000), new Date(1_000));
|
||||
await fs.utimes(path.join(packageB, "dist/entry.js"), new Date(9_000), new Date(9_000));
|
||||
await writeFixture(packageA);
|
||||
await writeFixture(packageB);
|
||||
await fs.mkdir(path.join(packageA, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(packageA, "dist", "entry.js"), 'import "json5";\n');
|
||||
await fs.writeFile(
|
||||
path.join(packageA, "dist", "control-ui", "index.html"),
|
||||
"<main>UI</main>",
|
||||
);
|
||||
await fs.utimes(
|
||||
path.join(packageA, "dist", "worker", "worker.mjs"),
|
||||
new Date(1_000),
|
||||
new Date(1_000),
|
||||
);
|
||||
await fs.utimes(
|
||||
path.join(packageB, "dist", "worker", "worker.mjs"),
|
||||
new Date(9_000),
|
||||
new Date(9_000),
|
||||
);
|
||||
|
||||
const first = await createWorkerBundleProducer({
|
||||
packageRoot: packageA,
|
||||
@@ -94,304 +102,55 @@ describe("worker bundle producer", () => {
|
||||
|
||||
expect(first.bundleHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(second.bundleHash).toBe(first.bundleHash);
|
||||
await expect(fs.stat(first.tarballPath)).resolves.toMatchObject({
|
||||
size: first.tarballBytes,
|
||||
});
|
||||
expect(nodeBuild).toEqual({
|
||||
bundleHash: first.bundleHash,
|
||||
openclawVersion: first.openclawVersion,
|
||||
protocolFeatures: first.protocolFeatures,
|
||||
});
|
||||
await expect(listTarball(first.tarballPath)).resolves.toEqual([
|
||||
"dist/entry.js",
|
||||
"dist/nested/worker.js",
|
||||
"openclaw.mjs",
|
||||
"package.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps lazy Control UI assets out of worker identity", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-control-ui-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export const entry = true;\n"]]);
|
||||
const beforeUiBuild = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
|
||||
await fs.mkdir(path.join(packageRoot, "dist/control-ui/assets"), { recursive: true });
|
||||
await fs.writeFile(path.join(packageRoot, "dist/control-ui/index.html"), "<main>UI</main>\n");
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist/control-ui/assets/app.js"),
|
||||
"console.log('ui');\n",
|
||||
);
|
||||
const afterUiBuild = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
|
||||
expect(afterUiBuild.bundleHash).toBe(beforeUiBuild.bundleHash);
|
||||
await expect(listTarball(afterUiBuild.tarballPath)).resolves.toEqual([
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"package.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes workspace deps and lifecycle fields from dev manifests", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-prune-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export const entry = true;\n"]]);
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
files: ["dist/"],
|
||||
dependencies: { json5: "2.2.3", "@openclaw/gateway-protocol": "workspace:*" },
|
||||
devDependencies: { vitest: "4.0.0" },
|
||||
scripts: { prepare: "node scripts/prepare.mjs" },
|
||||
pnpm: { patchedDependencies: {} },
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const bundle = await createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
openclawVersion: "1.2.3",
|
||||
}).prepare();
|
||||
const extractRoot = path.join(root, "extract");
|
||||
await fs.mkdir(extractRoot, { recursive: true });
|
||||
await tar.extract({ file: bundle.tarballPath, cwd: extractRoot });
|
||||
const staged = JSON.parse(
|
||||
await fs.readFile(path.join(extractRoot, "package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(staged.dependencies).toEqual({ json5: "2.2.3" });
|
||||
expect(staged).not.toHaveProperty("devDependencies");
|
||||
expect(staged).not.toHaveProperty("scripts");
|
||||
expect(staged).not.toHaveProperty("pnpm");
|
||||
});
|
||||
});
|
||||
|
||||
it("vendors workspace packages that the shipped dist imports", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-vendor-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
await writeFixture(packageRoot, [
|
||||
["dist/entry.js", 'import { fake } from "@openclaw/fake-pkg";\nexport { fake };\n'],
|
||||
]);
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
files: ["dist/"],
|
||||
dependencies: {
|
||||
json5: "2.2.3",
|
||||
"@openclaw/fake-pkg": "workspace:*",
|
||||
"@openclaw/gateway-protocol": "workspace:*",
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const vendorSource = path.join(packageRoot, "node_modules/@openclaw/fake-pkg");
|
||||
await fs.mkdir(path.join(vendorSource, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(vendorSource, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@openclaw/fake-pkg",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
main: "./dist/index.js",
|
||||
dependencies: {
|
||||
"@openclaw/fake-nested": "workspace:*",
|
||||
"partial-json": "0.1.7",
|
||||
},
|
||||
scripts: { build: "tsdown" },
|
||||
devDependencies: { vitest: "4.0.0" },
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(vendorSource, "dist/index.js"),
|
||||
"export const fake = true;\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const bundle = await createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
openclawVersion: "1.2.3",
|
||||
}).prepare();
|
||||
|
||||
await expect(listTarball(bundle.tarballPath)).resolves.toEqual([
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"package.json",
|
||||
"vendor/openclaw-fake-pkg/dist/index.js",
|
||||
"vendor/openclaw-fake-pkg/package.json",
|
||||
]);
|
||||
const extractRoot = path.join(root, "extract");
|
||||
await fs.mkdir(extractRoot, { recursive: true });
|
||||
await tar.extract({ file: bundle.tarballPath, cwd: extractRoot });
|
||||
const staged = JSON.parse(
|
||||
await fs.readFile(path.join(extractRoot, "package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(staged.dependencies).toEqual({
|
||||
json5: "2.2.3",
|
||||
"@openclaw/fake-pkg": "file:./vendor/openclaw-fake-pkg",
|
||||
});
|
||||
const vendored = JSON.parse(
|
||||
await fs.readFile(path.join(extractRoot, "vendor/openclaw-fake-pkg/package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(vendored.dependencies).toEqual({ "partial-json": "0.1.7" });
|
||||
expect(vendored).not.toHaveProperty("scripts");
|
||||
expect(vendored).not.toHaveProperty("devDependencies");
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(vendorSource, "dist/index.js"),
|
||||
'import { nested } from "@openclaw/fake-nested";\nexport const fake = nested;\n',
|
||||
"utf8",
|
||||
);
|
||||
await expect(
|
||||
createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache-with-runtime-workspace-dep"),
|
||||
openclawVersion: "1.2.3",
|
||||
}).prepare(),
|
||||
).rejects.toThrow(
|
||||
"Vendored workspace dependency @openclaw/fake-nested remains referenced by @openclaw/fake-pkg dist",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("installs a source bundle when the AI workspace import is bundled", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-npm-install-" }, async (root) => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const aiManifest = JSON.parse(
|
||||
await fs.readFile(path.join(repoRoot, "packages/ai/package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const dependencyFields = (["dependencies", "devDependencies"] as const).filter(
|
||||
(field) => aiManifest[field]?.["@openclaw/normalization-core"] !== undefined,
|
||||
);
|
||||
if (dependencyFields.length !== 1) {
|
||||
throw new Error(
|
||||
"@openclaw/ai must classify normalization-core in exactly one dependency field",
|
||||
);
|
||||
}
|
||||
const dependencyField = dependencyFields[0]!;
|
||||
const normalizationCoreSpec = aiManifest[dependencyField]?.["@openclaw/normalization-core"];
|
||||
if (!normalizationCoreSpec?.startsWith("workspace:")) {
|
||||
throw new Error("@openclaw/ai must use a workspace normalization-core dependency");
|
||||
}
|
||||
const packageRoot = path.join(root, "package");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", 'import "@openclaw/ai";\nexport {};\n']]);
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
files: ["dist/"],
|
||||
dependencies: { "@openclaw/ai": "workspace:*" },
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const vendorSource = path.join(packageRoot, "node_modules/@openclaw/ai");
|
||||
await fs.mkdir(path.join(vendorSource, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(vendorSource, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@openclaw/ai",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
main: "./dist/index.js",
|
||||
[dependencyField]: { "@openclaw/normalization-core": normalizationCoreSpec },
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(vendorSource, "dist/index.js"), "export {};\n", "utf8");
|
||||
|
||||
const bundle = await createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
}).prepare();
|
||||
await expect(listTarball(first.tarballPath)).resolves.toEqual(["worker.mjs"]);
|
||||
const extractRoot = path.join(root, "extract");
|
||||
await fs.mkdir(extractRoot);
|
||||
await tar.extract({ file: bundle.tarballPath, cwd: extractRoot });
|
||||
|
||||
const install = await runCommandWithTimeout(
|
||||
["npm", "install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"],
|
||||
{
|
||||
cwd: extractRoot,
|
||||
env: { NPM_CONFIG_CACHE: path.join(root, "npm-cache") },
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
await tar.extract({ file: first.tarballPath, cwd: extractRoot });
|
||||
await expect(fs.readFile(path.join(extractRoot, "worker.mjs"), "utf8")).resolves.toContain(
|
||||
"worker = true",
|
||||
);
|
||||
expect(install.code, install.stderr).toBe(0);
|
||||
await expect(fs.access(path.join(extractRoot, "package.json"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(extractRoot, "node_modules"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a dist-referenced workspace package is not installed", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-vendor-missing-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
await writeFixture(packageRoot, [
|
||||
["dist/entry.js", 'import "@openclaw/fake-pkg";\nexport {};\n'],
|
||||
]);
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "1.2.3",
|
||||
type: "module",
|
||||
files: ["dist/"],
|
||||
dependencies: { "@openclaw/fake-pkg": "workspace:*" },
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
openclawVersion: "1.2.3",
|
||||
}).prepare(),
|
||||
).rejects.toThrow("cannot resolve workspace dependency @openclaw/fake-pkg");
|
||||
});
|
||||
});
|
||||
|
||||
it("changes the hash when file contents change", async () => {
|
||||
it("changes identity only when the deploy artifact changes", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-change-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export const value = 1;\n"]]);
|
||||
await writeFixture(packageRoot, "export const value = 1;\n");
|
||||
const first = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist/entry.js"),
|
||||
"export const value = 2;\n",
|
||||
"utf8",
|
||||
path.join(packageRoot, "dist", "entry.js"),
|
||||
"export const unrelated = 2;\n",
|
||||
);
|
||||
const second = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
const unrelated = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
expect(unrelated.bundleHash).toBe(first.bundleHash);
|
||||
|
||||
expect(second.bundleHash).not.toBe(first.bundleHash);
|
||||
await expect(fs.stat(first.tarballPath)).resolves.toBeDefined();
|
||||
await expect(fs.stat(second.tarballPath)).resolves.toBeDefined();
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist", "worker", "worker.mjs"),
|
||||
"export const value = 2;\n",
|
||||
);
|
||||
const changed = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
expect(changed.bundleHash).not.toBe(first.bundleHash);
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes unreferenced bundles only for an exclusive cache owner", async () => {
|
||||
it("prunes only unretained bundles for an exclusive cache owner", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-prune-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export const value = 1;\n"]]);
|
||||
const previous = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
await writeFixture(packageRoot, "export const value = 1;\n");
|
||||
const retained = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist/entry.js"),
|
||||
path.join(packageRoot, "dist", "worker", "worker.mjs"),
|
||||
"export const value = 2;\n",
|
||||
"utf8",
|
||||
);
|
||||
const owner = createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
@@ -399,36 +158,14 @@ describe("worker bundle producer", () => {
|
||||
cacheOwnership: "exclusive",
|
||||
});
|
||||
const current = await owner.prepare();
|
||||
const removedPath = path.join(cacheDir, `${"c".repeat(64)}.tgz`);
|
||||
await fs.writeFile(removedPath, "historical");
|
||||
|
||||
await owner.prune([]);
|
||||
await owner.prune([retained.bundleHash]);
|
||||
|
||||
await expect(fs.stat(previous.tarballPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(current.tarballPath)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("retains durable hashes while pruning an exclusive cache", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-retain-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export const value = 1;\n"]]);
|
||||
const previous = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist/entry.js"),
|
||||
"export const value = 2;\n",
|
||||
"utf8",
|
||||
);
|
||||
const owner = createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir,
|
||||
cacheOwnership: "exclusive",
|
||||
});
|
||||
const current = await owner.prepare();
|
||||
|
||||
await owner.prune([previous.bundleHash]);
|
||||
|
||||
await expect(fs.stat(previous.tarballPath)).resolves.toBeDefined();
|
||||
await expect(fs.stat(retained.tarballPath)).resolves.toBeDefined();
|
||||
await expect(fs.stat(current.tarballPath)).resolves.toBeDefined();
|
||||
await expect(fs.stat(removedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,7 +173,7 @@ describe("worker bundle producer", () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-crash-cleanup-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
await writeFixture(packageRoot);
|
||||
const owner = createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir,
|
||||
@@ -450,8 +187,8 @@ describe("worker bundle producer", () => {
|
||||
);
|
||||
const unknown = path.join(cacheDir, "keep-me.txt");
|
||||
await fs.mkdir(staging);
|
||||
await fs.writeFile(temporary, "partial", "utf8");
|
||||
await fs.writeFile(unknown, "operator-owned", "utf8");
|
||||
await fs.writeFile(temporary, "partial");
|
||||
await fs.writeFile(unknown, "operator-owned");
|
||||
|
||||
await owner.prune([]);
|
||||
|
||||
@@ -462,32 +199,31 @@ describe("worker bundle producer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps custom caches non-destructive and failed preparations non-destructive", async () => {
|
||||
it("keeps custom caches non-destructive after failed preparation", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-shared-cache-" }, async (root) => {
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await fs.mkdir(cacheDir);
|
||||
const historical = path.join(cacheDir, `${"c".repeat(64)}.tgz`);
|
||||
await fs.writeFile(historical, "historical", "utf8");
|
||||
await fs.writeFile(historical, "historical");
|
||||
const shared = createWorkerBundleProducer({
|
||||
packageRoot: path.join(root, "missing-package"),
|
||||
cacheDir,
|
||||
});
|
||||
|
||||
await expect(shared.prepare()).rejects.toBeDefined();
|
||||
await expect(shared.prepare()).rejects.toThrow("worker deploy artifact is missing");
|
||||
await shared.prune([]);
|
||||
|
||||
await expect(fs.readFile(historical, "utf8")).resolves.toBe("historical");
|
||||
});
|
||||
});
|
||||
|
||||
it("archives the staged bytes when the source changes during packaging", async () => {
|
||||
it("archives staged bytes when the source changes during packaging", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-mutation-" }, async (root) => {
|
||||
const baselineRoot = path.join(root, "baseline");
|
||||
const packageRoot = path.join(root, "package");
|
||||
const originalContents = "export const value = 'before';\n";
|
||||
const changedContents = "export const value = 'after';\n";
|
||||
await writeFixture(baselineRoot, [["dist/entry.js", originalContents]]);
|
||||
await writeFixture(packageRoot, [["dist/entry.js", originalContents]]);
|
||||
await writeFixture(baselineRoot, originalContents);
|
||||
await writeFixture(packageRoot, originalContents);
|
||||
const baseline = await createWorkerBundleProducer({
|
||||
packageRoot: baselineRoot,
|
||||
cacheDir: path.join(root, "baseline-cache"),
|
||||
@@ -496,9 +232,12 @@ describe("worker bundle producer", () => {
|
||||
let sourceMutated = false;
|
||||
const chmodSpy = vi.spyOn(fs, "chmod").mockImplementation(async (filePath, mode) => {
|
||||
await originalChmod(filePath, mode);
|
||||
if (!sourceMutated && String(filePath).endsWith(`${path.sep}dist${path.sep}entry.js`)) {
|
||||
if (!sourceMutated && String(filePath).endsWith(`${path.sep}worker.mjs`)) {
|
||||
sourceMutated = true;
|
||||
await fs.writeFile(path.join(packageRoot, "dist/entry.js"), changedContents, "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "dist", "worker", "worker.mjs"),
|
||||
changedContents,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -513,55 +252,33 @@ describe("worker bundle producer", () => {
|
||||
|
||||
expect(sourceMutated).toBe(true);
|
||||
expect(artifact.bundleHash).toBe(baseline.bundleHash);
|
||||
await expect(fs.readFile(path.join(extractDir, "dist/entry.js"), "utf8")).resolves.toBe(
|
||||
await expect(fs.readFile(path.join(extractDir, "worker.mjs"), "utf8")).resolves.toBe(
|
||||
originalContents,
|
||||
);
|
||||
await expect(fs.readFile(path.join(packageRoot, "dist/entry.js"), "utf8")).resolves.toBe(
|
||||
changedContents,
|
||||
);
|
||||
} finally {
|
||||
chmodSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("owns one immutable build snapshot for its lifecycle", async () => {
|
||||
it("owns one immutable build snapshot and retries failed preparation", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-cache-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
const producer = createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
protocolFeatures: ["resume", "admission", "resume"],
|
||||
});
|
||||
|
||||
const firstPreparation = producer.prepare();
|
||||
const secondPreparation = producer.prepare();
|
||||
expect(secondPreparation).toBe(firstPreparation);
|
||||
const first = await firstPreparation;
|
||||
await fs.writeFile(path.join(packageRoot, "dist/entry.js"), "changed\n", "utf8");
|
||||
|
||||
await expect(producer.prepare()).resolves.toBe(first);
|
||||
expect(first.protocolFeatures).toEqual(["admission", "resume"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("retries after a failed preparation without polling a successful snapshot", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-retry-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const producer = createWorkerBundleProducer({
|
||||
packageRoot,
|
||||
cacheDir: path.join(root, "cache"),
|
||||
});
|
||||
|
||||
const failed = producer.prepare();
|
||||
await expect(failed).rejects.toBeDefined();
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
await expect(failed).rejects.toThrow("worker deploy artifact is missing");
|
||||
await writeFixture(packageRoot);
|
||||
|
||||
const retried = producer.prepare();
|
||||
expect(retried).not.toBe(failed);
|
||||
await expect(retried).resolves.toMatchObject({ install: "bundle" });
|
||||
expect(producer.prepare()).toBe(retried);
|
||||
const first = await retried;
|
||||
await fs.writeFile(path.join(packageRoot, "dist", "worker", "worker.mjs"), "changed\n");
|
||||
await expect(producer.prepare()).resolves.toBe(first);
|
||||
expect(first.protocolFeatures).toEqual(["admission", "resume"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -569,35 +286,28 @@ describe("worker bundle producer", () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-corrupt-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
const cacheDir = path.join(root, "cache");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
await writeFixture(packageRoot);
|
||||
const first = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
await fs.writeFile(first.tarballPath, "not a tarball", "utf8");
|
||||
await fs.writeFile(first.tarballPath, "not a tarball");
|
||||
|
||||
const repaired = await createWorkerBundleProducer({ packageRoot, cacheDir }).prepare();
|
||||
|
||||
expect(repaired.bundleHash).toBe(first.bundleHash);
|
||||
expect(repaired.tarballPath).toBe(first.tarballPath);
|
||||
await expect(listTarball(repaired.tarballPath)).resolves.toEqual([
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"package.json",
|
||||
]);
|
||||
await expect(listTarball(repaired.tarballPath)).resolves.toEqual(["worker.mjs"]);
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("rejects symlinked runtime files", async () => {
|
||||
it.skipIf(process.platform === "win32")("rejects a symlinked deploy artifact", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-bundle-symlink-" }, async (root) => {
|
||||
const packageRoot = path.join(root, "package");
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
await fs.rename(
|
||||
path.join(packageRoot, "openclaw.mjs"),
|
||||
path.join(packageRoot, "launcher-target.mjs"),
|
||||
);
|
||||
await fs.symlink("launcher-target.mjs", path.join(packageRoot, "openclaw.mjs"));
|
||||
await writeFixture(packageRoot);
|
||||
const artifactPath = path.join(packageRoot, "dist", "worker", "worker.mjs");
|
||||
await fs.rename(artifactPath, `${artifactPath}.target`);
|
||||
await fs.symlink("worker.mjs.target", artifactPath);
|
||||
|
||||
await expect(
|
||||
createWorkerBundleProducer({ packageRoot, cacheDir: path.join(root, "cache") }).prepare(),
|
||||
).rejects.toThrow("Unsafe worker bundle path: openclaw.mjs");
|
||||
).rejects.toThrow("Unsafe worker deploy artifact");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -605,7 +315,7 @@ describe("worker bundle producer", () => {
|
||||
describe("worker npm installation artifact", () => {
|
||||
it("uses an exact registry-proven gateway package", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-npm-release-" }, async (packageRoot) => {
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
await writeFixture(packageRoot);
|
||||
const packageIntegrity = `sha512-${Buffer.alloc(64).toString("base64")}`;
|
||||
const verifyRelease = vi.fn(async () => packageIntegrity);
|
||||
|
||||
@@ -653,7 +363,7 @@ describe("worker npm installation artifact", () => {
|
||||
|
||||
it("rejects a source checkout even when its version is published", async () => {
|
||||
await withTestDir({ prefix: "openclaw-worker-npm-source-" }, async (packageRoot) => {
|
||||
await writeFixture(packageRoot, [["dist/entry.js", "export {};\n"]]);
|
||||
await writeFixture(packageRoot);
|
||||
await fs.mkdir(path.join(packageRoot, ".git"));
|
||||
const verifyRelease = vi.fn(async () => `sha512-${Buffer.alloc(64).toString("base64")}`);
|
||||
|
||||
|
||||
@@ -13,15 +13,12 @@ import {
|
||||
readWorkerBundleArchiveManifest,
|
||||
} from "../../shared/worker-bundle-archive.js";
|
||||
import {
|
||||
compareWorkerBundlePaths,
|
||||
hashWorkerBundleManifest,
|
||||
WORKER_BUNDLE_MANIFEST_VERSION,
|
||||
} from "../../shared/worker-bundle-hash.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import {
|
||||
collectWorkerBundleManifest,
|
||||
comparePaths,
|
||||
type WorkerBundleManifestEntry,
|
||||
} from "./bundle-staging.js";
|
||||
import { collectWorkerBundleManifest, type WorkerBundleManifestEntry } from "./bundle-staging.js";
|
||||
|
||||
export { WORKER_BUNDLE_MANIFEST_VERSION };
|
||||
const OPENCLAW_NPM_REGISTRY = "https://registry.npmjs.org/";
|
||||
@@ -77,7 +74,7 @@ function normalizeProtocolFeatures(features: readonly string[]): string[] {
|
||||
if (normalized.some((feature) => feature.length === 0)) {
|
||||
throw new Error("Worker protocol features must be non-empty strings");
|
||||
}
|
||||
return [...new Set(normalized)].toSorted(comparePaths);
|
||||
return [...new Set(normalized)].toSorted(compareWorkerBundlePaths);
|
||||
}
|
||||
|
||||
function resolveBundleCacheDir(cacheDir: string | undefined): string {
|
||||
@@ -410,7 +407,9 @@ async function pruneWorkerBundleCache(params: {
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const entry of entries.toSorted((left, right) => comparePaths(left.name, right.name))) {
|
||||
for (const entry of entries.toSorted((left, right) =>
|
||||
compareWorkerBundlePaths(left.name, right.name),
|
||||
)) {
|
||||
const tarball = BUNDLE_TARBALL_NAME_PATTERN.exec(entry.name);
|
||||
const removableTarball = tarball && !retained.has(tarball[1]!);
|
||||
const removableStaging = BUNDLE_STAGING_NAME_PATTERN.test(entry.name);
|
||||
|
||||
@@ -2,11 +2,7 @@ import {
|
||||
type WorkerAdmissionHandshake,
|
||||
WORKER_RPC_SET_VERSION,
|
||||
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import {
|
||||
resolveLocalWorkerBuild,
|
||||
verifyWorkerAdmissionHandshake,
|
||||
type ExpectedWorkerBuild,
|
||||
} from "./admission.js";
|
||||
import { verifyWorkerAdmissionHandshake, type ExpectedWorkerBuild } from "./admission.js";
|
||||
import type { WorkerInstallationArtifact } from "./bundle.js";
|
||||
import {
|
||||
createWorkerCredentialMaterial,
|
||||
@@ -124,7 +120,7 @@ export function createWorkerCredentialBroker(options: WorkerCredentialBrokerOpti
|
||||
|
||||
const commitReady = (
|
||||
record: WorkerEnvironmentRecord,
|
||||
receipt: WorkerAdmissionHandshake & { installKind: "bundle" | "local" },
|
||||
receipt: WorkerAdmissionHandshake & { installKind: "bundle" },
|
||||
patch: WorkerEnvironmentTransitionPatch = {},
|
||||
) => {
|
||||
const material = credentialMaterial();
|
||||
@@ -220,9 +216,7 @@ export function createWorkerCredentialBroker(options: WorkerCredentialBrokerOpti
|
||||
}
|
||||
let currentBuild: ExpectedWorkerBuild;
|
||||
try {
|
||||
currentBuild =
|
||||
resolveLocalWorkerBuild(current.bootstrapReceipt) ??
|
||||
(await options.prepareInstallation("bundle"));
|
||||
currentBuild = await options.prepareInstallation("bundle");
|
||||
} catch {
|
||||
throw serviceError("invalid_state", "Current worker build identity is unavailable");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import type { PairedDevice } from "../../infra/device-pairing.types.js";
|
||||
import { NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE } from "../../infra/node-runner-inventory.js";
|
||||
import {
|
||||
NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
} from "../../infra/node-runner-inventory.js";
|
||||
import { WorkerProviderError } from "../../plugins/types.js";
|
||||
import type { NodeWorkerSupervisorNodeProof } from "../node-registry-private.js";
|
||||
import {
|
||||
@@ -64,6 +67,7 @@ function connectedNode(
|
||||
function deviceRuntime(params: {
|
||||
getPairedDevice: (deviceId: string) => Promise<PairedDevice | null>;
|
||||
listCurrentNodes?: () => Promise<readonly NodeWorkerSupervisorNodeProof[]>;
|
||||
getIssue?: () => typeof NODE_RUNNER_UPDATE_REQUIRED_ISSUE | undefined;
|
||||
now?: () => number;
|
||||
}) {
|
||||
const runtime = createDeviceWorkerRuntime({
|
||||
@@ -73,6 +77,7 @@ function deviceRuntime(params: {
|
||||
if (params.listCurrentNodes) {
|
||||
runtime.bindNodeTransport({
|
||||
listCurrentNodes: params.listCurrentNodes,
|
||||
...(params.getIssue ? { getIssue: params.getIssue } : {}),
|
||||
isCurrent: () => true,
|
||||
invoke: async () => ({ ok: false }),
|
||||
});
|
||||
@@ -135,6 +140,18 @@ describe("device worker provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the exact update-and-reconnect recovery for an outdated connected node", async () => {
|
||||
const provider = deviceRuntime({
|
||||
getPairedDevice: async () => pairedDevice(),
|
||||
listCurrentNodes: async () => [],
|
||||
getIssue: () => NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
}).provider;
|
||||
|
||||
await expect(provider.provision({ device: DEVICE_ID }, "operation")).rejects.toThrow(
|
||||
`device worker node ${DEVICE_ID} requires an update before it can host sessions; run openclaw update, then reconnect it (for a headless node, run openclaw node restart)`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "inside the dormancy ceiling",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { hasEffectivePairedDeviceRole } from "../../infra/device-pairing.js";
|
||||
import type { PairedDevice } from "../../infra/device-pairing.types.js";
|
||||
import {
|
||||
formatNodeRunnerUpdateRequired,
|
||||
type NodeRunnerInventoryIssue,
|
||||
} from "../../infra/node-runner-inventory.js";
|
||||
import {
|
||||
WorkerProviderError,
|
||||
type WorkerProfile,
|
||||
@@ -20,24 +24,28 @@ type DeviceWorkerRuntimeOptions = {
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type DeviceWorkerAvailability = (deviceId: string) => Promise<boolean>;
|
||||
type DeviceWorkerAvailability = {
|
||||
available: boolean;
|
||||
issue?: NodeRunnerInventoryIssue;
|
||||
};
|
||||
type DeviceWorkerAvailabilityResolver = (deviceId: string) => Promise<DeviceWorkerAvailability>;
|
||||
type DeviceWorkerReconciliation = (deviceId: string) => Promise<readonly string[]>;
|
||||
const DEVICE_WORKER_AVAILABILITY = new WeakMap<object, DeviceWorkerAvailability>();
|
||||
const DEVICE_WORKER_AVAILABILITY = new WeakMap<object, DeviceWorkerAvailabilityResolver>();
|
||||
const DEVICE_WORKER_RECONCILIATION = new WeakMap<object, DeviceWorkerReconciliation>();
|
||||
|
||||
export function bindDeviceWorkerAvailability(
|
||||
service: object,
|
||||
isAvailable: DeviceWorkerAvailability,
|
||||
resolveAvailability: DeviceWorkerAvailabilityResolver,
|
||||
): void {
|
||||
DEVICE_WORKER_AVAILABILITY.set(service, isAvailable);
|
||||
DEVICE_WORKER_AVAILABILITY.set(service, resolveAvailability);
|
||||
}
|
||||
|
||||
export async function isDeviceWorkerAvailable(
|
||||
export async function resolveDeviceWorkerAvailability(
|
||||
service: object | undefined,
|
||||
deviceId: string,
|
||||
): Promise<boolean> {
|
||||
const isAvailable = service ? DEVICE_WORKER_AVAILABILITY.get(service) : undefined;
|
||||
return isAvailable ? await isAvailable(deviceId) : false;
|
||||
): Promise<DeviceWorkerAvailability> {
|
||||
const resolveAvailability = service ? DEVICE_WORKER_AVAILABILITY.get(service) : undefined;
|
||||
return resolveAvailability ? await resolveAvailability(deviceId) : { available: false };
|
||||
}
|
||||
|
||||
export function bindDeviceWorkerReconciliation(
|
||||
@@ -95,21 +103,29 @@ export function createDeviceWorkerRuntime(options: DeviceWorkerRuntimeOptions) {
|
||||
const node = await findConnectedNode(deviceId);
|
||||
return node && isSessionCapableNode(node) ? node : undefined;
|
||||
};
|
||||
const isAvailable = async (deviceId: string) => {
|
||||
const resolveAvailability = async (deviceId: string): Promise<DeviceWorkerAvailability> => {
|
||||
const [paired, connected] = await Promise.all([
|
||||
options.getPairedDevice(deviceId),
|
||||
findAvailableNode(deviceId),
|
||||
]);
|
||||
return hasPairedNodeRole(paired) && Boolean(connected);
|
||||
const issue = nodeTransport?.getIssue?.(deviceId);
|
||||
return {
|
||||
available: hasPairedNodeRole(paired) && Boolean(connected),
|
||||
...(issue ? { issue } : {}),
|
||||
};
|
||||
};
|
||||
const isAvailable = async (deviceId: string) => (await resolveAvailability(deviceId)).available;
|
||||
const provider: WorkerProvider = {
|
||||
id: DEVICE_WORKER_PROVIDER_ID,
|
||||
provisionBeforeInstallation: true,
|
||||
provision: async (profile, operationId) => {
|
||||
const deviceId = requireDeviceId(profile);
|
||||
if (!(await isAvailable(deviceId))) {
|
||||
const availability = await resolveAvailability(deviceId);
|
||||
if (!availability.available) {
|
||||
throw new WorkerProviderError(
|
||||
`device worker is not a connected session-capable paired node: ${deviceId}`,
|
||||
availability.issue
|
||||
? formatNodeRunnerUpdateRequired(deviceId, availability.issue)
|
||||
: `device worker is not a connected session-capable paired node: ${deviceId}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -136,12 +152,9 @@ export function createDeviceWorkerRuntime(options: DeviceWorkerRuntimeOptions) {
|
||||
return {
|
||||
provider,
|
||||
isAvailable,
|
||||
resolveAvailability,
|
||||
launchNodeWorker: launchAdapter.launch,
|
||||
getNodeTransport: () => nodeTransport,
|
||||
// Provisioning reads the node-advertised local-install build through the
|
||||
// runtime so node lookups keep one owner; absent means not connected or
|
||||
// not session-capable, and the caller fails provisioning closed.
|
||||
resolveWorkerBuild: async (deviceId: string) => (await findAvailableNode(deviceId))?.workerRuns,
|
||||
bindNodeTransport: (transport: NodeWorkerSupervisorTransport) => {
|
||||
nodeTransport = transport;
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import * as support from "./service.test-support.js";
|
||||
import { createWorkerEnvironmentStore } from "./store.js";
|
||||
import type { WorkerTunnelManager } from "./tunnel.js";
|
||||
@@ -105,7 +104,7 @@ describe("worker environment service", () => {
|
||||
expect(tunnelManager.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a local-install node tunnel without entering SSH", async () => {
|
||||
it("starts a Gateway-bundle node tunnel without entering SSH", async () => {
|
||||
const tunnelManager = {
|
||||
status: () => "stopped" as const,
|
||||
start: vi.fn(),
|
||||
@@ -148,11 +147,7 @@ describe("worker environment service", () => {
|
||||
{
|
||||
tunnelManager,
|
||||
nodeTunnelManager,
|
||||
resolveNodeWorkerBuild: async () => ({
|
||||
bundleHash: "c".repeat(64),
|
||||
openclawVersion: VERSION,
|
||||
protocolFeatures: ["worker-heartbeat-v1"],
|
||||
}),
|
||||
ensureNodeWorkerBundle: async () => structuredClone(support.BOOTSTRAP_RECEIPT),
|
||||
},
|
||||
);
|
||||
const environment = await workerService.create("development", "device-tunnel-gate");
|
||||
@@ -171,12 +166,12 @@ describe("worker environment service", () => {
|
||||
}),
|
||||
).resolves.toMatchObject({ environmentId: environment.environmentId });
|
||||
expect(tunnelManager.start).not.toHaveBeenCalled();
|
||||
expect(prepareInstallation).toHaveBeenCalledTimes(prepareCallsBeforeTunnel);
|
||||
expect(prepareInstallation).toHaveBeenCalledTimes(prepareCallsBeforeTunnel + 1);
|
||||
expect(nodeTunnelManager.start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceId: "device-1",
|
||||
sessionId: "session-device",
|
||||
expectedBuild: expect.objectContaining({ bundleHash: "c".repeat(64) }),
|
||||
expectedBuild: expect.objectContaining({ bundleHash: support.BUNDLE_HASH }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -219,11 +214,7 @@ describe("worker environment service", () => {
|
||||
{
|
||||
tunnelManager,
|
||||
nodeTunnelManager,
|
||||
resolveNodeWorkerBuild: async () => ({
|
||||
bundleHash: "c".repeat(64),
|
||||
openclawVersion: VERSION,
|
||||
protocolFeatures: ["worker-heartbeat-v1"],
|
||||
}),
|
||||
ensureNodeWorkerBundle: async () => structuredClone(support.BOOTSTRAP_RECEIPT),
|
||||
},
|
||||
);
|
||||
const environment = await workerService.create("development", "device-tunnel-timeout");
|
||||
|
||||
@@ -120,11 +120,23 @@ export function createWorkerEnvironmentAccess(options: WorkerEnvironmentAccessOp
|
||||
) {
|
||||
throw serviceError("invalid_state", "Worker tunnel owner credential is not current");
|
||||
}
|
||||
const nodeLocal =
|
||||
let currentBundle: ExpectedWorkerBuild;
|
||||
try {
|
||||
currentBundle = await options.prepareCurrentBundle();
|
||||
} catch {
|
||||
throw serviceError("invalid_state", "Current worker build identity is unavailable");
|
||||
}
|
||||
if (!verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle)) {
|
||||
throw serviceError(
|
||||
"invalid_state",
|
||||
"Worker must bootstrap the current build before continuing",
|
||||
);
|
||||
}
|
||||
const nodeBundle =
|
||||
record.providerId === DEVICE_WORKER_PROVIDER_ID &&
|
||||
!record.sshEndpoint &&
|
||||
record.bootstrapReceipt.installKind === "local";
|
||||
if (nodeLocal) {
|
||||
record.bootstrapReceipt.installKind === "bundle";
|
||||
if (nodeBundle) {
|
||||
const profileSettings = record.profileSnapshot.settings;
|
||||
const deviceId = isRecord(profileSettings) ? profileSettings.device : undefined;
|
||||
const sessionId = record.attachedSessionIds[0];
|
||||
@@ -137,9 +149,9 @@ export function createWorkerEnvironmentAccess(options: WorkerEnvironmentAccessOp
|
||||
deviceId: deviceId.trim(),
|
||||
sessionId,
|
||||
expectedBuild: {
|
||||
bundleHash: record.bootstrapReceipt.bundleHash,
|
||||
openclawVersion: record.bootstrapReceipt.openclawVersion,
|
||||
protocolFeatures: [...record.bootstrapReceipt.protocolFeatures],
|
||||
bundleHash: currentBundle.bundleHash,
|
||||
openclawVersion: currentBundle.openclawVersion,
|
||||
protocolFeatures: [...currentBundle.protocolFeatures],
|
||||
},
|
||||
});
|
||||
stopStartup = async () => await nodeTunnels.stop(record.environmentId, record.ownerEpoch);
|
||||
@@ -155,18 +167,6 @@ export function createWorkerEnvironmentAccess(options: WorkerEnvironmentAccessOp
|
||||
if (!gateway) {
|
||||
throw serviceError("invalid_state", "Worker gateway ingress is unavailable");
|
||||
}
|
||||
let currentBundle: ExpectedWorkerBuild;
|
||||
try {
|
||||
currentBundle = await options.prepareCurrentBundle();
|
||||
} catch {
|
||||
throw serviceError("invalid_state", "Current worker build identity is unavailable");
|
||||
}
|
||||
if (!verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle)) {
|
||||
throw serviceError(
|
||||
"invalid_state",
|
||||
"Worker must bootstrap the current build before continuing",
|
||||
);
|
||||
}
|
||||
const provider = providerFor(record.providerId);
|
||||
// Tunnel ownership is registered synchronously by the manager. Release the durable-state
|
||||
// lock while SSH connects so drain/destroy can fence an indefinitely reconnecting start.
|
||||
|
||||
@@ -48,7 +48,6 @@ function launchInput(): NodeWorkerLaunchInput {
|
||||
return {
|
||||
launchId: "turn-1",
|
||||
gatewayNamespace: "gateway-1",
|
||||
installKind: "local",
|
||||
expectedBundleHash: WORKER_RUNS.bundleHash,
|
||||
placementGeneration: 4,
|
||||
descriptor: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { WorkerAdmissionHandshake } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import { sleepWithAbort } from "../../infra/backoff.js";
|
||||
import {
|
||||
NODE_WORKER_CAPACITY_EXHAUSTED_ERROR_CODE,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
type NodeWorkerSupervisorIdentity,
|
||||
type NodeWorkerSupervisorReceipt,
|
||||
} from "../../worker/node-supervisor-protocol.js";
|
||||
import { sameWorkerBuild } from "../../worker/worker-build-identity.js";
|
||||
import type {
|
||||
NodeWorkerSupervisorNodeProof,
|
||||
NodeWorkerSupervisorTransport,
|
||||
@@ -211,7 +209,7 @@ export function createNodeWorkerLaunchAdapter(options: NodeWorkerLaunchAdapterOp
|
||||
const findNode = async (params: {
|
||||
transport: NodeWorkerSupervisorTransport;
|
||||
deviceId: string;
|
||||
expectedWorkerRuns?: WorkerAdmissionHandshake;
|
||||
requireLaunchAvailability?: boolean;
|
||||
signal: AbortSignal;
|
||||
}): Promise<NodeWorkerSupervisorNodeProof> => {
|
||||
let nodes: readonly NodeWorkerSupervisorNodeProof[];
|
||||
@@ -229,9 +227,7 @@ export function createNodeWorkerLaunchAdapter(options: NodeWorkerLaunchAdapterOp
|
||||
const node = nodes.find(
|
||||
(candidate) =>
|
||||
candidate.nodeId === params.deviceId &&
|
||||
(!params.expectedWorkerRuns ||
|
||||
(candidate.workerRuns &&
|
||||
sameWorkerBuild(candidate.workerRuns, params.expectedWorkerRuns))),
|
||||
(!params.requireLaunchAvailability || candidate.workerRuns !== undefined),
|
||||
);
|
||||
if (!node) {
|
||||
throw new NodeWorkerLaunchTransportError(
|
||||
@@ -249,7 +245,7 @@ export function createNodeWorkerLaunchAdapter(options: NodeWorkerLaunchAdapterOp
|
||||
| typeof NODE_WORKER_SUPERVISOR_STATUS_COMMAND
|
||||
| typeof NODE_WORKER_SUPERVISOR_CANCEL_COMMAND;
|
||||
payload: unknown;
|
||||
expectedWorkerRuns?: WorkerAdmissionHandshake;
|
||||
requireLaunchAvailability?: boolean;
|
||||
isAuthorized: () => boolean;
|
||||
deadline: OperationDeadline;
|
||||
onDispatchReady?: () => void;
|
||||
@@ -288,7 +284,7 @@ export function createNodeWorkerLaunchAdapter(options: NodeWorkerLaunchAdapterOp
|
||||
const node = await findNode({
|
||||
transport,
|
||||
deviceId: params.deviceId,
|
||||
expectedWorkerRuns: params.expectedWorkerRuns,
|
||||
requireLaunchAvailability: params.requireLaunchAvailability,
|
||||
signal,
|
||||
});
|
||||
const operation = transport.invoke({
|
||||
@@ -451,7 +447,7 @@ export function createNodeWorkerLaunchAdapter(options: NodeWorkerLaunchAdapterOp
|
||||
? NODE_WORKER_SUPERVISOR_STATUS_COMMAND
|
||||
: NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND,
|
||||
payload: pollStatus ? { launchId: input.launchId } : input,
|
||||
...(!pollStatus ? { expectedWorkerRuns: input.descriptor.admission.handshake } : {}),
|
||||
...(!pollStatus ? { requireLaunchAvailability: true } : {}),
|
||||
isAuthorized: stableRequest.isDispatchAuthorized,
|
||||
deadline: attemptDeadline,
|
||||
...(!pollStatus
|
||||
|
||||
@@ -69,7 +69,3 @@ export function createGatewayNodeWorkerBundleInstaller(options: {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewayNodeWorkerBundleInstaller = ReturnType<
|
||||
typeof createGatewayNodeWorkerBundleInstaller
|
||||
>;
|
||||
|
||||
@@ -4,7 +4,7 @@ import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE } from "../../infra/node-runner-inventory.js";
|
||||
import { NodeWorkerBundleInstaller } from "../../node-host/node-worker-bundle-installer.js";
|
||||
@@ -42,22 +42,15 @@ describe("node worker bundle transfer", () => {
|
||||
it("streams one authorized Gateway artifact into an atomic node install", async () => {
|
||||
const source = path.join(root, "source");
|
||||
const tarballPath = path.join(root, "bundle.tgz");
|
||||
await fs.mkdir(path.join(source, "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "openclaw.mjs"), "#!/usr/bin/env node\n");
|
||||
await fs.chmod(path.join(source, "openclaw.mjs"), 0o700);
|
||||
await fs.writeFile(path.join(source, "package.json"), '{"name":"openclaw"}\n');
|
||||
await fs.chmod(path.join(source, "package.json"), 0o600);
|
||||
await fs.writeFile(path.join(source, "dist", "worker.js"), "export {};\n");
|
||||
await fs.chmod(path.join(source, "dist", "worker.js"), 0o600);
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "worker.mjs"), "export {};\n", { mode: 0o700 });
|
||||
const manifest = await readWorkerBundleDirectoryManifest({
|
||||
root: source,
|
||||
limits: DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
});
|
||||
const bundleHash = hashWorkerBundleManifest(manifest);
|
||||
await tar.create({ cwd: source, file: tarballPath, gzip: true, noDirRecurse: true }, [
|
||||
"dist/worker.js",
|
||||
"openclaw.mjs",
|
||||
"package.json",
|
||||
"worker.mjs",
|
||||
]);
|
||||
const tarball = await fs.readFile(tarballPath);
|
||||
const service = createNodeWorkerBundleTransferService({
|
||||
@@ -103,17 +96,7 @@ describe("node worker bundle transfer", () => {
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server did not bind a TCP port");
|
||||
}
|
||||
const installer = new NodeWorkerBundleInstaller({
|
||||
root: path.join(root, "node-host"),
|
||||
runCommand: vi.fn(async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
})),
|
||||
});
|
||||
const installer = new NodeWorkerBundleInstaller({ root: path.join(root, "node-host") });
|
||||
|
||||
await expect(
|
||||
installer.ensure({
|
||||
|
||||
@@ -59,7 +59,7 @@ function environment(): WorkerEnvironmentRecord {
|
||||
provisionOperationId: "provision-1",
|
||||
sharedHost: true,
|
||||
desktop: null,
|
||||
bootstrapReceipt: { ...BUILD, installKind: "local" },
|
||||
bootstrapReceipt: { ...BUILD, installKind: "bundle" },
|
||||
ownerEpoch: 2,
|
||||
teardownTerminalState: null,
|
||||
attachedSessionIds: ["session-1"],
|
||||
|
||||
@@ -64,7 +64,6 @@ type NodeWorkerLaunch = (request: {
|
||||
input: {
|
||||
launchId: string;
|
||||
gatewayNamespace: string;
|
||||
installKind: "local";
|
||||
expectedBundleHash: string;
|
||||
placementGeneration: number;
|
||||
descriptor: Parameters<WorkerTunnelHandle["launchTurn"]>[0]["plan"];
|
||||
@@ -192,7 +191,7 @@ export function createNodeWorkerTunnelManager(options: NodeWorkerTunnelManagerOp
|
||||
return Boolean(
|
||||
current &&
|
||||
current.ownerEpoch === entry.ownerEpoch &&
|
||||
current.bootstrapReceipt?.installKind === "local" &&
|
||||
current.bootstrapReceipt?.installKind === "bundle" &&
|
||||
sameWorkerBuild(current.bootstrapReceipt, entry.expectedBuild) &&
|
||||
current.attachedSessionIds.length <= 1 &&
|
||||
(current.attachedSessionIds.length === 0 ||
|
||||
@@ -218,13 +217,10 @@ export function createNodeWorkerTunnelManager(options: NodeWorkerTunnelManagerOp
|
||||
throw new Error("device worker node transport is unavailable");
|
||||
}
|
||||
const node = (await raceWithSignal(transport.listCurrentNodes(), signal)).find(
|
||||
(candidate) =>
|
||||
candidate.nodeId === entry.deviceId &&
|
||||
candidate.workerBuild &&
|
||||
sameWorkerBuild(candidate.workerBuild, entry.expectedBuild),
|
||||
(candidate) => candidate.nodeId === entry.deviceId,
|
||||
);
|
||||
if (!node) {
|
||||
throw new Error("device worker node is not connected with the expected build");
|
||||
throw new Error("device worker node is not connected with the supervisor dialect");
|
||||
}
|
||||
return { transport, node };
|
||||
};
|
||||
@@ -531,7 +527,6 @@ export function createNodeWorkerTunnelManager(options: NodeWorkerTunnelManagerOp
|
||||
input: {
|
||||
launchId: plan.assignment.turnId,
|
||||
gatewayNamespace,
|
||||
installKind: "local",
|
||||
expectedBundleHash: entry.expectedBuild.bundleHash,
|
||||
placementGeneration: request.placementGeneration,
|
||||
descriptor: plan,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NODE_WORKER_WORKSPACE_RETAIN_COMMAND } from "../../infra/node-commands.js";
|
||||
import { NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE } from "../../infra/node-runner-inventory.js";
|
||||
import type { NodeWorkerSupervisorTransport } from "../node-registry-private.js";
|
||||
import { createNodeWorkspaceRetainCoordinator } from "./node-workspace-retain-coordinator.js";
|
||||
import type { WorkerSessionPlacementStore } from "./placement-store.js";
|
||||
@@ -12,7 +13,7 @@ const node = {
|
||||
pairingGeneration: "generation-1",
|
||||
clientId: "node-host",
|
||||
clientMode: "node",
|
||||
protocolFeature: "node-worker-supervisor-v1",
|
||||
protocolFeature: NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
commands: [],
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WORKER_EXECUTION_CONTEXT_PROTOCOL_FEATURE } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { NODE_RUNNER_UPDATE_REQUIRED_ISSUE } from "../../infra/node-runner-inventory.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
@@ -30,7 +31,7 @@ describe("device worker placement dispatch", () => {
|
||||
|
||||
it("provisions, syncs, and activates a local-install device environment", async () => {
|
||||
const harness = createHarness(placementStore);
|
||||
bindDeviceWorkerAvailability(harness.environments, async () => true);
|
||||
bindDeviceWorkerAvailability(harness.environments, async () => ({ available: true }));
|
||||
vi.mocked(harness.environments.createFromProfileSnapshot).mockResolvedValue({
|
||||
...harness.ready,
|
||||
providerId: "device",
|
||||
@@ -42,7 +43,7 @@ describe("device worker placement dispatch", () => {
|
||||
bundleHash: "a".repeat(64),
|
||||
openclawVersion: "2026.8.12",
|
||||
protocolFeatures: [WORKER_EXECUTION_CONTEXT_PROTOCOL_FEATURE],
|
||||
installKind: "local",
|
||||
installKind: "bundle",
|
||||
},
|
||||
sharedHost: true,
|
||||
tunnelStatus: "stopped",
|
||||
@@ -82,7 +83,10 @@ describe("device worker placement dispatch", () => {
|
||||
|
||||
it("records an unavailable device dispatch as a durable failed placement", async () => {
|
||||
const harness = createHarness(placementStore);
|
||||
bindDeviceWorkerAvailability(harness.environments, async () => false);
|
||||
bindDeviceWorkerAvailability(harness.environments, async () => ({
|
||||
available: false,
|
||||
issue: NODE_RUNNER_UPDATE_REQUIRED_ISSUE,
|
||||
}));
|
||||
const states: string[] = [];
|
||||
const request = {
|
||||
...REQUEST,
|
||||
@@ -99,15 +103,17 @@ describe("device worker placement dispatch", () => {
|
||||
|
||||
await expect(
|
||||
harness.service.dispatch(request, (placement) => states.push(placement.state)),
|
||||
).rejects.toThrow("device worker requires a connected current node host");
|
||||
).rejects.toThrow(
|
||||
"device worker node offline-device requires an update before it can host sessions; run openclaw update, then reconnect it (for a headless node, run openclaw node restart)",
|
||||
);
|
||||
|
||||
expect(states).toEqual(["requested", "failed"]);
|
||||
expect(harness.environments.createFromProfileSnapshot).not.toHaveBeenCalled();
|
||||
expect(createWorkerSessionPlacementStore({ database }).get(REQUEST.sessionId)).toMatchObject({
|
||||
state: "failed",
|
||||
environmentId: null,
|
||||
recoveryError: expect.stringContaining("connected current node host"),
|
||||
terminalReason: expect.stringContaining("connected current node host"),
|
||||
recoveryError: expect.stringContaining("run openclaw update"),
|
||||
terminalReason: expect.stringContaining("run openclaw node restart"),
|
||||
terminalAtMs: 1_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { formatNodeRunnerUpdateRequired } from "../../infra/node-runner-inventory.js";
|
||||
import { supportsWorkerExecutionContextLaunch } from "./admission.js";
|
||||
import { DEVICE_WORKER_PROVIDER_ID, isDeviceWorkerAvailable } from "./device-provider.js";
|
||||
import { DEVICE_WORKER_PROVIDER_ID, resolveDeviceWorkerAvailability } from "./device-provider.js";
|
||||
import {
|
||||
createPlacementFailureActions,
|
||||
isUnavailableEnvironment,
|
||||
@@ -95,7 +96,7 @@ function requireProvisionedEnvironment(
|
||||
if (
|
||||
environment.providerId === DEVICE_WORKER_PROVIDER_ID &&
|
||||
!environment.sshEndpoint &&
|
||||
environment.bootstrapReceipt?.installKind === "local" &&
|
||||
environment.bootstrapReceipt?.installKind === "bundle" &&
|
||||
supportsWorkerExecutionContextLaunch(environment.bootstrapReceipt)
|
||||
) {
|
||||
return {
|
||||
@@ -183,9 +184,14 @@ export function createWorkerPlacementDispatchService(options: WorkerPlacementDis
|
||||
return placement;
|
||||
},
|
||||
});
|
||||
if (request.deviceId && !(await isDeviceWorkerAvailable(environments, request.deviceId))) {
|
||||
const deviceAvailability = request.deviceId
|
||||
? await resolveDeviceWorkerAvailability(environments, request.deviceId)
|
||||
: undefined;
|
||||
if (request.deviceId && !deviceAvailability?.available) {
|
||||
throw new Error(
|
||||
`device worker requires a connected current node host; reconnect or reprovision: ${request.deviceId}`,
|
||||
deviceAvailability?.issue
|
||||
? formatNodeRunnerUpdateRequired(request.deviceId, deviceAvailability.issue)
|
||||
: `device worker requires a connected current node host; reconnect or reprovision: ${request.deviceId}`,
|
||||
);
|
||||
}
|
||||
const localPath = await options.resolveWorkspacePath(request);
|
||||
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
type WorkerSshEndpoint,
|
||||
type WorkerSshIdentity,
|
||||
} from "../../plugins/types.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { resolveLocalWorkerBuild, verifyWorkerAdmissionHandshake } from "./admission.js";
|
||||
import { verifyWorkerAdmissionHandshake } from "./admission.js";
|
||||
import type { WorkerInstallationArtifact } from "./bundle.js";
|
||||
import type { WorkerCredentialBroker } from "./credential-broker.js";
|
||||
import { deriveEnvironmentIntent } from "./service-contract.js";
|
||||
@@ -51,7 +50,7 @@ type WorkerProviderLifecycleOptions = {
|
||||
profile: WorkerProfile;
|
||||
keyRef: SecretRef;
|
||||
}) => Promise<WorkerSshIdentity>;
|
||||
resolveNodeWorkerBuild?: (deviceId: string) => Promise<WorkerAdmissionHandshake | undefined>;
|
||||
ensureNodeWorkerBundle?: (deviceId: string) => Promise<WorkerAdmissionHandshake>;
|
||||
providerCallTimeoutMs?: number;
|
||||
tunnelManager?: Pick<WorkerTunnelManager, "stop">;
|
||||
credentialBroker: WorkerCredentialBroker;
|
||||
@@ -276,22 +275,20 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp
|
||||
desktop: lease.desktop ?? null,
|
||||
};
|
||||
if (lease.node) {
|
||||
const nodeBuild = await options.resolveNodeWorkerBuild?.(lease.node.deviceId);
|
||||
if (!nodeBuild) {
|
||||
const detail = `Device worker no longer advertises session hosting: ${lease.node.deviceId}`;
|
||||
let nodeBuild: WorkerAdmissionHandshake;
|
||||
try {
|
||||
if (!options.ensureNodeWorkerBundle) {
|
||||
throw new Error("Device worker bundle installer is unavailable");
|
||||
}
|
||||
nodeBuild = await options.ensureNodeWorkerBundle(lease.node.deviceId);
|
||||
} catch (error) {
|
||||
const detail = boundedError(error);
|
||||
move(record, "failed", { lastError: detail });
|
||||
throw serviceError("bootstrap_failure", detail);
|
||||
throw serviceError("bootstrap_failure", `Device worker bootstrap failed: ${detail}`);
|
||||
}
|
||||
if (nodeBuild.openclawVersion !== VERSION) {
|
||||
const detail = `Device worker runs OpenClaw ${nodeBuild.openclawVersion}, but this gateway runs ${VERSION}; update the node to match the gateway, then retry`;
|
||||
move(record, "failed", { lastError: detail });
|
||||
throw serviceError("bootstrap_failure", detail);
|
||||
}
|
||||
// Admin pairing already trusts this machine. Pinning its exact claimed hash plus an exact
|
||||
// version match prevents skew; milestone 7 replaces the claim with Gateway-pushed bytes.
|
||||
return commitReady(
|
||||
record,
|
||||
{ ...nodeBuild, installKind: "local" },
|
||||
{ ...nodeBuild, installKind: "bundle" },
|
||||
{ ...patch, sshEndpoint: null },
|
||||
);
|
||||
}
|
||||
@@ -391,12 +388,10 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp
|
||||
}
|
||||
let currentBundle: WorkerInstallationArtifact | undefined;
|
||||
if (record.destroyRequestedAtMs === null && inState(record, "ready", "idle", "attached")) {
|
||||
const localBuild = resolveLocalWorkerBuild(record.bootstrapReceipt);
|
||||
try {
|
||||
currentBundle = localBuild ? undefined : await options.prepareInstallation("bundle");
|
||||
const expectedBuild = localBuild ?? currentBundle;
|
||||
if (record.bootstrapReceipt && expectedBuild) {
|
||||
if (verifyWorkerAdmissionHandshake(record.bootstrapReceipt, expectedBuild)) {
|
||||
currentBundle = await options.prepareInstallation("bundle");
|
||||
if (record.bootstrapReceipt) {
|
||||
if (verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle)) {
|
||||
const sessionId = record.state === "attached" ? record.attachedSessionIds[0] : null;
|
||||
if (record.state !== "attached" || sessionId) {
|
||||
ensurePendingCredential(record, sessionId ?? null);
|
||||
@@ -489,8 +484,16 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp
|
||||
return;
|
||||
}
|
||||
if (!record.sshEndpoint) {
|
||||
// Node leases deliberately have no SSH bootstrap path; their transport owner advances
|
||||
// this lifecycle once supervised node launch is available.
|
||||
if (
|
||||
currentBundle &&
|
||||
(!record.bootstrapReceipt ||
|
||||
!verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle))
|
||||
) {
|
||||
// A stale node environment cannot be upgraded in place because its credential and
|
||||
// placement ownership bind the old build. Retire it; reprovisioning reuses the installed
|
||||
// content-addressed bundle without another transfer.
|
||||
await finishDestroy(record, provider).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (record.state === "attached") {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import type { GatewaySessionRow } from "../session-utils.types.js";
|
||||
import { writeSessionStore } from "../test-helpers.js";
|
||||
import { directSessionReq } from "../test/server-sessions.test-helpers.js";
|
||||
@@ -78,15 +77,8 @@ describe("worker environment service", () => {
|
||||
expect(workerService.takeMintedCredential(binding)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("commits a local-install receipt and credential for a node lease", async () => {
|
||||
const workerBuild = {
|
||||
bundleHash: "c".repeat(64),
|
||||
openclawVersion: VERSION,
|
||||
protocolFeatures: ["worker-heartbeat-v1"],
|
||||
};
|
||||
support.testState.prepareInstallation = vi.fn(async () => {
|
||||
throw new Error("node leases must not prepare an SSH installation");
|
||||
});
|
||||
it("commits an installed Gateway bundle receipt and credential for a node lease", async () => {
|
||||
const workerBuild = structuredClone(support.BOOTSTRAP_RECEIPT);
|
||||
const workerService = support.createService(
|
||||
support.createProvider({
|
||||
provisionBeforeInstallation: true,
|
||||
@@ -96,7 +88,7 @@ describe("worker environment service", () => {
|
||||
sharedHost: true,
|
||||
}),
|
||||
}),
|
||||
{ resolveNodeWorkerBuild: async () => workerBuild },
|
||||
{ ensureNodeWorkerBundle: async () => workerBuild },
|
||||
);
|
||||
|
||||
const result = await workerService.create("development", "request-device");
|
||||
@@ -105,7 +97,7 @@ describe("worker environment service", () => {
|
||||
state: "ready",
|
||||
leaseId: "device-lease-1",
|
||||
sshEndpoint: null,
|
||||
bootstrapReceipt: { ...workerBuild, installKind: "local" },
|
||||
bootstrapReceipt: { ...workerBuild, installKind: "bundle" },
|
||||
sharedHost: true,
|
||||
ownerEpoch: 1,
|
||||
});
|
||||
@@ -118,7 +110,7 @@ describe("worker environment service", () => {
|
||||
});
|
||||
expect(credential).toMatchObject({
|
||||
credential: support.CREDENTIAL,
|
||||
bundleHash: "c".repeat(64),
|
||||
bundleHash: support.BUNDLE_HASH,
|
||||
});
|
||||
const attachedCredential = await workerService.attachSession({
|
||||
environmentId: result.environmentId,
|
||||
@@ -156,34 +148,31 @@ describe("worker environment service", () => {
|
||||
).toEqual({ ok: false, reason: "bundle-mismatch" });
|
||||
});
|
||||
|
||||
it("fails node provisioning visibly when the node version differs", async () => {
|
||||
const nodeVersion = "0.0.0-node";
|
||||
it("fails node provisioning visibly when Gateway bundle installation fails", async () => {
|
||||
const workerService = support.createService(
|
||||
support.createProvider({
|
||||
provisionBeforeInstallation: true,
|
||||
provision: async () => ({
|
||||
leaseId: "device-lease-version-mismatch",
|
||||
leaseId: "device-lease-install-failure",
|
||||
node: { deviceId: "device-1" },
|
||||
}),
|
||||
}),
|
||||
{
|
||||
resolveNodeWorkerBuild: async () => ({
|
||||
bundleHash: "c".repeat(64),
|
||||
openclawVersion: nodeVersion,
|
||||
protocolFeatures: ["worker-heartbeat-v1"],
|
||||
}),
|
||||
ensureNodeWorkerBundle: async () => {
|
||||
throw new Error("bundle transfer unavailable");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
workerService.create("development", "request-device-mismatch"),
|
||||
workerService.create("development", "request-device-install-failure"),
|
||||
).rejects.toMatchObject({
|
||||
code: "bootstrap_failure",
|
||||
message: expect.stringContaining(`OpenClaw ${nodeVersion}`),
|
||||
message: expect.stringContaining("bundle transfer unavailable"),
|
||||
} satisfies Partial<WorkerEnvironmentServiceError>);
|
||||
expect(support.testState.store.list()[0]).toMatchObject({
|
||||
state: "failed",
|
||||
lastError: expect.stringContaining(`gateway runs ${VERSION}`),
|
||||
lastError: expect.stringContaining("bundle transfer unavailable"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -171,6 +171,37 @@ describe("worker environment service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retires a node environment whose installed Gateway bundle is stale", async () => {
|
||||
const destroy = vi.fn(async () => {});
|
||||
const provider = support.createProvider({
|
||||
provisionBeforeInstallation: true,
|
||||
provision: async () => ({
|
||||
leaseId: "device-lease-stale",
|
||||
node: { deviceId: "device-1" },
|
||||
sharedHost: true,
|
||||
}),
|
||||
inspect: async () => ({ status: "active", sharedHost: true }),
|
||||
destroy,
|
||||
});
|
||||
const workerService = support.createService(provider, {
|
||||
ensureNodeWorkerBundle: async () => structuredClone(support.BOOTSTRAP_RECEIPT),
|
||||
});
|
||||
const environment = await workerService.create("development", "request-stale-node-bundle");
|
||||
support.testState.stateDb.db
|
||||
.prepare(
|
||||
"UPDATE worker_environments SET bootstrap_bundle_hash = ?, bootstrap_install_kind = 'local' WHERE environment_id = ?",
|
||||
)
|
||||
.run("b".repeat(64), environment.environmentId);
|
||||
|
||||
await workerService.reconcileOnce();
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(support.testState.store.get(environment.environmentId)).toMatchObject({
|
||||
state: "destroyed",
|
||||
attachedSessionIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve npm while an admitted receipt matches the local bundle", async () => {
|
||||
const environmentId = "worker-current-npm";
|
||||
support.seedReady(environmentId, "npm");
|
||||
|
||||
@@ -162,7 +162,7 @@ export function createService(
|
||||
| "executeInference"
|
||||
| "providerCallTimeoutMs"
|
||||
| "resolveSshIdentity"
|
||||
| "resolveNodeWorkerBuild"
|
||||
| "ensureNodeWorkerBundle"
|
||||
| "resolveWorkerGateway"
|
||||
| "tunnelManager"
|
||||
| "generateWorkerCredential"
|
||||
|
||||
@@ -88,7 +88,7 @@ type WorkerEnvironmentServiceOptions = {
|
||||
profile: WorkerProfile;
|
||||
keyRef: SecretRef;
|
||||
}) => Promise<WorkerSshIdentity>;
|
||||
resolveNodeWorkerBuild?: (deviceId: string) => Promise<WorkerAdmissionHandshake | undefined>;
|
||||
ensureNodeWorkerBundle?: (deviceId: string) => Promise<WorkerAdmissionHandshake>;
|
||||
tunnelManager?: WorkerTunnelManager;
|
||||
nodeTunnelManager?: NodeWorkerTunnelManager;
|
||||
stopNodeWorkerBundleTransfers?: () => void;
|
||||
@@ -279,7 +279,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
|
||||
prepareInstallation: options.prepareInstallation,
|
||||
bootstrapWorker: options.bootstrapWorker,
|
||||
resolveSshIdentity: options.resolveSshIdentity,
|
||||
resolveNodeWorkerBuild: options.resolveNodeWorkerBuild,
|
||||
ensureNodeWorkerBundle: options.ensureNodeWorkerBundle,
|
||||
providerCallTimeoutMs: options.providerCallTimeoutMs,
|
||||
tunnelManager: tunnelLifecycle,
|
||||
credentialBroker,
|
||||
|
||||
@@ -112,9 +112,7 @@ describe("worker tunnel manager", () => {
|
||||
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('exec node "$HOME/.openclaw-worker/$1/worker.mjs"');
|
||||
expect(remoteLaunchCommand).toContain(`'${BUNDLE_HASH}'`);
|
||||
expect(launch?.options.input).toContain('"connectionEndpoint":{"kind":"unix"');
|
||||
expect(launch?.options.timeoutMs).toBe(123);
|
||||
|
||||
@@ -79,7 +79,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';
|
||||
const WORKER_LAUNCH_SCRIPT = 'exec node "$HOME/.openclaw-worker/$1/worker.mjs"';
|
||||
|
||||
type WorkerTunnelStartRequest = WorkerTunnelRequest & {
|
||||
bundleHash: string;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { safeEqualSecret } from "../../security/secret-equal.js";
|
||||
import type { WorkerSessionToolName } from "../../worker/tool-authority.js";
|
||||
import {
|
||||
admitWorkerConnection,
|
||||
resolveLocalWorkerBuild,
|
||||
validateWorkerConnectionIdentity,
|
||||
type ExpectedWorkerBuild,
|
||||
type WorkerConnectionIdentity,
|
||||
@@ -523,9 +522,7 @@ export function createWorkerTurnRpc(options: WorkerTurnRpcOptions) {
|
||||
}
|
||||
let expectedBuild: ExpectedWorkerBuild;
|
||||
try {
|
||||
expectedBuild =
|
||||
resolveLocalWorkerBuild(store.get(admission.environmentId)?.bootstrapReceipt) ??
|
||||
(await options.prepareInstallation("bundle"));
|
||||
expectedBuild = await options.prepareInstallation("bundle");
|
||||
} catch {
|
||||
return { ok: false, reason: "environment-unavailable" } as const;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const formatCommit = (value?: string | null) => {
|
||||
|
||||
const cachedGitCommitBySearchDir = new Map<string, string | null>();
|
||||
const GIT_COMMIT_CACHE_LIMIT = 256;
|
||||
declare const WORKER_DEPLOY_BUILD: boolean;
|
||||
|
||||
type CommitMetadataReaders = {
|
||||
readGitCommit?: (searchDir: string, packageRoot: string | null) => string | null | undefined;
|
||||
@@ -168,6 +169,9 @@ const resolveRefPath = (refsBase: string, ref: string) => {
|
||||
};
|
||||
|
||||
const readCommitFromPackageJson = () => {
|
||||
if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../../package.json") as {
|
||||
|
||||
@@ -5,10 +5,21 @@ import {
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
export const NODE_RUNNER_INVENTORY_UPDATE_METHOD = "node.runnerInventory.update";
|
||||
export const NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE = "node-worker-supervisor-v1";
|
||||
export const NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE = "node-worker-supervisor-v2";
|
||||
export const NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE = "node-worker-supervisor-v1";
|
||||
|
||||
export const NODE_RUNNER_UPDATE_REQUIRED_ISSUE = {
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
} as const;
|
||||
|
||||
export type NodeRunnerInventoryIssue = typeof NODE_RUNNER_UPDATE_REQUIRED_ISSUE;
|
||||
|
||||
type NodeWorkerSupervisorProtocolFeatures =
|
||||
| readonly []
|
||||
| readonly [typeof NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE]
|
||||
| readonly [typeof NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE];
|
||||
|
||||
export type NodeRunnerInventoryDeclaration = {
|
||||
@@ -37,8 +48,11 @@ export function parseNodeRunnerInventoryDeclaration(
|
||||
let protocolFeatures: NodeWorkerSupervisorProtocolFeatures;
|
||||
if (value.protocolFeatures.length === 0) {
|
||||
protocolFeatures = [];
|
||||
} else if (value.protocolFeatures[0] === NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE) {
|
||||
protocolFeatures = [NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE];
|
||||
} else if (
|
||||
value.protocolFeatures[0] === NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE ||
|
||||
value.protocolFeatures[0] === NODE_WORKER_SUPERVISOR_LEGACY_PROTOCOL_FEATURE
|
||||
) {
|
||||
protocolFeatures = [value.protocolFeatures[0]];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -51,3 +65,10 @@ export function parseNodeRunnerInventoryDeclaration(
|
||||
}
|
||||
return { protocolFeatures };
|
||||
}
|
||||
|
||||
export function formatNodeRunnerUpdateRequired(
|
||||
nodeId: string,
|
||||
issue: NodeRunnerInventoryIssue,
|
||||
): string {
|
||||
return `device worker node ${nodeId} requires an update before it can host sessions; run ${issue.updateCommand}, then reconnect it (for a headless node, run ${issue.headlessReconnectCommand})`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { resolveSecureTempRoot } from "@openclaw/fs-safe/temp";
|
||||
export type ResolveSecureTempRoot = typeof import("@openclaw/fs-safe/temp").resolveSecureTempRoot;
|
||||
@@ -1,4 +1,5 @@
|
||||
// Creates temporary OpenClaw directories for runtime scratch work.
|
||||
import { getWorkerDeploySecureTempRoot } from "../worker/worker-deploy-runtime-registry.js";
|
||||
|
||||
/** Preferred shared OpenClaw temp root on POSIX systems when ownership and permissions are safe. */
|
||||
export const DEFAULT_POSIX_TMP_ROOT = "/tmp/openclaw";
|
||||
@@ -25,11 +26,20 @@ export type ResolvePreferredOpenClawTmpDirOptions = {
|
||||
type ResolveSecureTempRoot = typeof import("@openclaw/fs-safe/temp").resolveSecureTempRoot;
|
||||
|
||||
let resolveSecureTempRootRuntime: ResolveSecureTempRoot | undefined;
|
||||
declare const WORKER_DEPLOY_BUILD: boolean;
|
||||
|
||||
function loadResolveSecureTempRoot(): ResolveSecureTempRoot {
|
||||
if (resolveSecureTempRootRuntime) {
|
||||
return resolveSecureTempRootRuntime;
|
||||
}
|
||||
const injected = getWorkerDeploySecureTempRoot();
|
||||
if (injected) {
|
||||
resolveSecureTempRootRuntime = injected;
|
||||
return injected;
|
||||
}
|
||||
if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) {
|
||||
throw new Error("worker temp-root runtime was not registered before use");
|
||||
}
|
||||
// Keep this module browser-import safe: fs-safe's temp barrel owns Node-only
|
||||
// workspaces, so load it only when the Node runtime actually resolves a temp root.
|
||||
const getBuiltinModule = (
|
||||
|
||||
@@ -138,9 +138,19 @@ describe("tsdown config", () => {
|
||||
expect(cacheKeyGenerator?.({ id: path.resolve(rootDir, "src/index.ts") })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("installs schema inlining only on the unified runtime graph", () => {
|
||||
it("installs schema inlining only on executable runtime graphs", () => {
|
||||
const configs = asConfigArray(tsdownConfig);
|
||||
const unifiedGraph = requireUnifiedDistGraph();
|
||||
const inlinePlugins = asConfigArray(tsdownConfig).flatMap(
|
||||
const workerGraph = configs.find((config) => {
|
||||
const entry = config.entry;
|
||||
return (
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
!Array.isArray(entry) &&
|
||||
(entry as Record<string, unknown>)["worker/worker"] === "src/worker/worker-deploy-entry.ts"
|
||||
);
|
||||
});
|
||||
const inlinePlugins = configs.flatMap(
|
||||
(config) =>
|
||||
config.plugins?.filter((plugin) => plugin.name === STATE_SCHEMA_INLINE_PLUGIN_NAME) ?? [],
|
||||
);
|
||||
@@ -148,7 +158,10 @@ describe("tsdown config", () => {
|
||||
expect(unifiedGraph.plugins).toContainEqual(
|
||||
expect.objectContaining({ name: STATE_SCHEMA_INLINE_PLUGIN_NAME }),
|
||||
);
|
||||
expect(inlinePlugins).toHaveLength(1);
|
||||
expect(workerGraph?.plugins).toContainEqual(
|
||||
expect.objectContaining({ name: STATE_SCHEMA_INLINE_PLUGIN_NAME }),
|
||||
);
|
||||
expect(inlinePlugins).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps core, plugin runtime, plugin-sdk, bundled root plugins, and bundled hooks in one dist graph", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ type NodeWorkerBuildOptions = {
|
||||
protocolFeatures?: readonly string[];
|
||||
};
|
||||
|
||||
export type NodeWorkerInstallation = {
|
||||
type NodeWorkerInstallation = {
|
||||
packageRoot: string;
|
||||
build: WorkerAdmissionHandshake;
|
||||
revalidateBuild(): Promise<boolean>;
|
||||
|
||||
@@ -5,7 +5,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
readWorkerBundleDirectoryManifest,
|
||||
@@ -33,29 +32,33 @@ describe("node worker bundle installer", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function bundleFixture(): Promise<{
|
||||
async function bundleFixture(options: { packageShell?: boolean } = {}): Promise<{
|
||||
archive: Buffer;
|
||||
input: NodeWorkerBundleInstallInput;
|
||||
}> {
|
||||
const source = path.join(root, "source");
|
||||
const archivePath = path.join(root, "bundle.tgz");
|
||||
await fs.mkdir(path.join(source, "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "openclaw.mjs"), "#!/usr/bin/env node\n");
|
||||
await fs.chmod(path.join(source, "openclaw.mjs"), 0o700);
|
||||
await fs.writeFile(path.join(source, "package.json"), '{"name":"openclaw"}\n');
|
||||
await fs.chmod(path.join(source, "package.json"), 0o600);
|
||||
await fs.writeFile(path.join(source, "dist", "worker.js"), "export {};\n");
|
||||
await fs.chmod(path.join(source, "dist", "worker.js"), 0o600);
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "worker.mjs"), "export {};\n", { mode: 0o700 });
|
||||
const archiveEntries = ["worker.mjs"];
|
||||
if (options.packageShell) {
|
||||
await fs.mkdir(path.join(source, "dist"));
|
||||
await fs.writeFile(path.join(source, "openclaw.mjs"), "#!/usr/bin/env node\n", {
|
||||
mode: 0o700,
|
||||
});
|
||||
await fs.writeFile(path.join(source, "package.json"), '{"name":"openclaw"}\n');
|
||||
await fs.writeFile(path.join(source, "dist", "worker.js"), "export {};\n");
|
||||
archiveEntries.push("dist/worker.js", "openclaw.mjs", "package.json");
|
||||
}
|
||||
const manifest = await readWorkerBundleDirectoryManifest({
|
||||
root: source,
|
||||
limits: DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
});
|
||||
const bundleHash = hashWorkerBundleManifest(manifest);
|
||||
await tar.create({ cwd: source, file: archivePath, gzip: true, noDirRecurse: true }, [
|
||||
"dist/worker.js",
|
||||
"openclaw.mjs",
|
||||
"package.json",
|
||||
]);
|
||||
await tar.create(
|
||||
{ cwd: source, file: archivePath, gzip: true, noDirRecurse: true },
|
||||
archiveEntries,
|
||||
);
|
||||
const archive = await fs.readFile(archivePath);
|
||||
return {
|
||||
archive,
|
||||
@@ -106,15 +109,7 @@ describe("node worker bundle installer", () => {
|
||||
);
|
||||
await fs.mkdir(staleStaging, { recursive: true });
|
||||
const served = await serve(fixture.archive, fixture.input.archive.token);
|
||||
const runCommand = vi.fn<typeof runCommandWithTimeout>(async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
}));
|
||||
const installer = new NodeWorkerBundleInstaller({ root, runCommand });
|
||||
const installer = new NodeWorkerBundleInstaller({ root });
|
||||
|
||||
await expect(
|
||||
installer.ensure({ input: fixture.input, gatewayUrl: served.gatewayUrl }),
|
||||
@@ -124,9 +119,7 @@ describe("node worker bundle installer", () => {
|
||||
).resolves.toEqual(fixture.input.build);
|
||||
|
||||
expect(served.requests).toHaveBeenCalledOnce();
|
||||
expect(runCommand).toHaveBeenCalledOnce();
|
||||
await expect(fs.access(staleStaging)).rejects.toThrow();
|
||||
expect(runCommand.mock.calls[0]?.[0]).toContain("--ignore-scripts");
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
@@ -141,18 +134,36 @@ describe("node worker bundle installer", () => {
|
||||
).resolves.toContain(fixture.input.build.bundleHash);
|
||||
});
|
||||
|
||||
it("reinstalls when executable dependency material appears outside the bundle hash", async () => {
|
||||
const fixture = await bundleFixture({ packageShell: true });
|
||||
const served = await serve(fixture.archive, fixture.input.archive.token);
|
||||
const installer = new NodeWorkerBundleInstaller({ root });
|
||||
const bundleDir = path.join(
|
||||
root,
|
||||
fixture.input.gatewayNamespace,
|
||||
"bundles",
|
||||
fixture.input.build.bundleHash,
|
||||
);
|
||||
const tamperedDependency = path.join(bundleDir, "node_modules", "tampered", "index.js");
|
||||
|
||||
await installer.ensure({ input: fixture.input, gatewayUrl: served.gatewayUrl });
|
||||
await fs.mkdir(path.dirname(tamperedDependency), { recursive: true });
|
||||
await fs.writeFile(tamperedDependency, "export const trusted = false;\n");
|
||||
await installer.ensure({ input: fixture.input, gatewayUrl: served.gatewayUrl });
|
||||
|
||||
expect(served.requests).toHaveBeenCalledTimes(2);
|
||||
await expect(fs.access(tamperedDependency)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects archive digest mismatch without publishing a bundle", async () => {
|
||||
const fixture = await bundleFixture();
|
||||
fixture.input.archive.sha256 = "f".repeat(64);
|
||||
const served = await serve(fixture.archive, fixture.input.archive.token);
|
||||
const installer = new NodeWorkerBundleInstaller({
|
||||
root,
|
||||
runCommand: vi.fn(),
|
||||
});
|
||||
const installer = new NodeWorkerBundleInstaller({ root });
|
||||
|
||||
await expect(
|
||||
installer.ensure({ input: fixture.input, gatewayUrl: served.gatewayUrl }),
|
||||
).rejects.toThrow("bundle installation did not complete");
|
||||
).rejects.toThrow("worker bundle download failed integrity validation");
|
||||
await expect(
|
||||
fs.access(
|
||||
path.join(root, fixture.input.gatewayNamespace, "bundles", fixture.input.build.bundleHash),
|
||||
@@ -167,10 +178,10 @@ describe("node worker bundle installer", () => {
|
||||
fixture.input.archive.token,
|
||||
fixture.archive.byteLength + 1,
|
||||
);
|
||||
const installer = new NodeWorkerBundleInstaller({ root, runCommand: vi.fn() });
|
||||
const installer = new NodeWorkerBundleInstaller({ root });
|
||||
|
||||
await expect(
|
||||
installer.ensure({ input: fixture.input, gatewayUrl: served.gatewayUrl }),
|
||||
).rejects.toThrow("bundle installation did not complete");
|
||||
).rejects.toThrow("gateway returned an unexpected worker bundle length");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import {
|
||||
validateWorkerAdmissionHandshake,
|
||||
type WorkerAdmissionHandshake,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
extractWorkerBundleArchive,
|
||||
readWorkerBundleDirectoryManifest,
|
||||
} from "../shared/worker-bundle-archive.js";
|
||||
import { hashWorkerBundleManifest } from "../shared/worker-bundle-hash.js";
|
||||
import {
|
||||
hashWorkerBundleManifest,
|
||||
WORKER_BUNDLE_ENTRY_PATH,
|
||||
} from "../shared/worker-bundle-hash.js";
|
||||
import { MAX_WORKER_BUNDLE_ARCHIVE_BYTES } from "../shared/worker-bundle-limits.js";
|
||||
import {
|
||||
nodeWorkerBundleTransferPath,
|
||||
@@ -30,28 +35,7 @@ import {
|
||||
} from "./node-worker-transfer-http.js";
|
||||
|
||||
const INSTALL_RECEIPT = "bootstrap-receipt.json";
|
||||
const INSTALL_TIMEOUT_MS = 35 * 60_000;
|
||||
const INSTALL_IGNORED_TOP_LEVEL = new Set(["node_modules", INSTALL_RECEIPT]);
|
||||
|
||||
type BundleInstallCommandRunner = typeof runCommandWithTimeout;
|
||||
|
||||
function commandEnv(homeDir: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
HOME: homeDir,
|
||||
...(process.platform === "win32" ? { USERPROFILE: homeDir } : {}),
|
||||
CI: "1",
|
||||
GIT_ASKPASS: "",
|
||||
GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
NPM_CONFIG_AUDIT: "false",
|
||||
NPM_CONFIG_FUND: "false",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false",
|
||||
SSH_ASKPASS: "",
|
||||
};
|
||||
}
|
||||
const INSTALL_IGNORED_TOP_LEVEL = new Set([INSTALL_RECEIPT]);
|
||||
|
||||
async function responseBody(response: IncomingMessage, maxBytes = 64 * 1024): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -104,10 +88,7 @@ async function downloadBundle(params: {
|
||||
}
|
||||
hash.update(chunk);
|
||||
if (!output.write(chunk)) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.once("drain", resolve);
|
||||
output.once("error", reject);
|
||||
});
|
||||
await once(output, "drain");
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -158,7 +139,7 @@ async function validateInstalledBundle(
|
||||
return false;
|
||||
}
|
||||
const root = await fsp.realpath(bundleDir);
|
||||
const entry = await fsp.realpath(path.join(root, "openclaw.mjs"));
|
||||
const entry = await fsp.realpath(path.join(root, WORKER_BUNDLE_ENTRY_PATH));
|
||||
return isPathInside(root, entry) && (await fsp.stat(entry)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
@@ -202,21 +183,11 @@ async function publishBundle(destination: string, staging: string): Promise<void
|
||||
|
||||
export class NodeWorkerBundleInstaller {
|
||||
readonly #root: string;
|
||||
readonly #env: NodeJS.ProcessEnv;
|
||||
readonly #runCommand: BundleInstallCommandRunner;
|
||||
readonly #operations = new KeyedAsyncQueue();
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
root?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runCommand?: BundleInstallCommandRunner;
|
||||
} = {},
|
||||
) {
|
||||
constructor(options: { root?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
this.#root = path.resolve(options.root ?? path.join(resolveStateDir(env), "node-host"));
|
||||
this.#env = { ...env };
|
||||
this.#runCommand = options.runCommand ?? runCommandWithTimeout;
|
||||
}
|
||||
|
||||
async ensure(params: {
|
||||
@@ -244,8 +215,6 @@ export class NodeWorkerBundleInstaller {
|
||||
try {
|
||||
const archivePath = path.join(operationRoot, "bundle.tgz");
|
||||
const staging = path.join(operationRoot, "root");
|
||||
const homeDir = path.join(operationRoot, "home");
|
||||
await fsp.mkdir(homeDir, { mode: 0o700 });
|
||||
await downloadBundle({
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
gatewayTlsFingerprint: params.gatewayTlsFingerprint,
|
||||
@@ -259,38 +228,6 @@ export class NodeWorkerBundleInstaller {
|
||||
expectedBundleHash: input.build.bundleHash,
|
||||
limits: DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
});
|
||||
const install = await this.#runCommand(
|
||||
[
|
||||
"npm",
|
||||
"install",
|
||||
"--prefix",
|
||||
staging,
|
||||
"--ignore-scripts",
|
||||
"--omit=dev",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--package-lock=false",
|
||||
],
|
||||
{
|
||||
cwd: staging,
|
||||
baseEnv: commandEnv(homeDir, this.#env),
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
signal: params.signal,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
maxCombinedOutputBytes: 512 * 1024,
|
||||
},
|
||||
);
|
||||
if (install.termination !== "exit" || install.code !== 0) {
|
||||
throw new Error("worker bundle dependency installation failed");
|
||||
}
|
||||
const installedManifest = await readWorkerBundleDirectoryManifest({
|
||||
root: staging,
|
||||
limits: DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
ignoreTopLevel: new Set(["node_modules"]),
|
||||
});
|
||||
if (hashWorkerBundleManifest(installedManifest) !== input.build.bundleHash) {
|
||||
throw new Error("worker bundle changed during dependency installation");
|
||||
}
|
||||
const receipt = await fsp.open(path.join(staging, INSTALL_RECEIPT), "wx", 0o600);
|
||||
try {
|
||||
await receipt.writeFile(`${JSON.stringify(input.build)}\n`);
|
||||
@@ -318,8 +255,12 @@ export class NodeWorkerBundleInstaller {
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const detail = truncateUtf16Safe(
|
||||
redactSensitiveText(error instanceof Error ? error.message : String(error)),
|
||||
512,
|
||||
);
|
||||
throw new NodeWorkerBundleInstallError(
|
||||
"worker-bundle-install-failed: bundle installation did not complete",
|
||||
`worker-bundle-install-failed: ${detail || "bundle installation did not complete"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import type { NodeWorkerLaunchInput } from "../worker/node-supervisor-protocol.js";
|
||||
import type { NodeWorkerInstallation } from "./node-worker-build.js";
|
||||
import { WORKER_BUNDLE_ENTRY_PATH } from "../shared/worker-bundle-hash.js";
|
||||
|
||||
/** Resolves an explicitly selected worker install without crossing local/bundle trust modes. */
|
||||
export async function resolveNodeWorkerEntry(params: {
|
||||
/** Resolves one exact Gateway-managed worker bundle from its isolated namespace. */
|
||||
export function resolveNodeWorkerEntry(params: {
|
||||
bundleRoot: string;
|
||||
installKind: NodeWorkerLaunchInput["installKind"];
|
||||
expectedBundleHash: string;
|
||||
gatewayNamespace: string;
|
||||
localInstallation?: NodeWorkerInstallation;
|
||||
}): Promise<string> {
|
||||
if (params.installKind === "local") {
|
||||
const installation = params.localInstallation;
|
||||
if (!installation || installation.build.bundleHash !== params.expectedBundleHash) {
|
||||
throw new Error("node worker local install does not match its advertised build");
|
||||
}
|
||||
if (!(await installation.revalidateBuild())) {
|
||||
throw new Error("node worker local install changed after its build was advertised");
|
||||
}
|
||||
const root = fs.realpathSync.native(installation.packageRoot);
|
||||
const entry = fs.realpathSync.native(path.join(root, "openclaw.mjs"));
|
||||
if (!isPathInside(root, entry) || !fs.statSync(entry).isFile()) {
|
||||
throw new Error("node worker local entry must be a regular file inside its install");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}): string {
|
||||
const root = fs.realpathSync.native(params.bundleRoot);
|
||||
const bundle = fs.realpathSync.native(
|
||||
path.join(root, params.gatewayNamespace, "bundles", params.expectedBundleHash),
|
||||
@@ -34,7 +16,7 @@ export async function resolveNodeWorkerEntry(params: {
|
||||
if (!isPathInside(root, bundle)) {
|
||||
throw new Error("node worker bundle resolves outside its configured root");
|
||||
}
|
||||
const entry = fs.realpathSync.native(path.join(bundle, "openclaw.mjs"));
|
||||
const entry = fs.realpathSync.native(path.join(bundle, WORKER_BUNDLE_ENTRY_PATH));
|
||||
if (!isPathInside(bundle, entry) || !fs.statSync(entry).isFile()) {
|
||||
throw new Error("node worker entry must be a regular file inside its bundle");
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ function planHash(input: ReturnType<typeof testWorkerLaunchInput>): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
installKind: input.installKind,
|
||||
expectedBundleHash: input.expectedBundleHash,
|
||||
descriptor: input.descriptor,
|
||||
gatewayNamespace: input.gatewayNamespace,
|
||||
|
||||
@@ -202,7 +202,7 @@ export function writeNodeWorkerFixture(root: string) {
|
||||
const bundleDir = path.join(bundleRoot, "gateway-1", "bundles", TEST_BUNDLE_HASH);
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(bundleDir, "openclaw.mjs"), TEST_WORKER_SOURCE);
|
||||
fs.writeFileSync(path.join(bundleDir, "worker.mjs"), TEST_WORKER_SOURCE);
|
||||
return { bundleRoot, env: { OPENCLAW_STATE_DIR: stateDir }, root, stateDir, workspaceDir };
|
||||
}
|
||||
|
||||
@@ -214,7 +214,6 @@ export function testWorkerLaunchInput(
|
||||
return {
|
||||
launchId,
|
||||
gatewayNamespace: "gateway-1",
|
||||
installKind: "bundle",
|
||||
expectedBundleHash: TEST_BUNDLE_HASH,
|
||||
placementGeneration: 4,
|
||||
descriptor: testWorkerDescriptor(workspaceDir, prompt),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { resolveNodeWorkerInstallation } from "./node-worker-build.js";
|
||||
import { NodeWorkerLaunchStore } from "./node-worker-launch-store.js";
|
||||
import {
|
||||
inspectNodeWorkerProcessIdentity,
|
||||
@@ -193,7 +191,7 @@ describe("node worker supervisor", () => {
|
||||
const completed = await waitForTerminal(supervisor, input.launchId);
|
||||
expect(completed).toMatchObject({ state: "completed", errorText: null });
|
||||
expect(JSON.parse(completed.resultJson ?? "null")).toEqual({
|
||||
argv: ["worker", "--internal-worker-ipc"],
|
||||
argv: ["--internal-worker-ipc"],
|
||||
status: "completed",
|
||||
});
|
||||
expect(await supervisor.launch(input, TEST_WORKER_ENDPOINT)).toEqual(completed);
|
||||
@@ -307,56 +305,6 @@ describe("node worker supervisor", () => {
|
||||
expect(new NodeWorkerLaunchStore({ env }).get(waiting.launchId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses the advertised local install and refuses it after its worker bytes change", async () => {
|
||||
const root = tempDirs.make("node-worker-local-install-");
|
||||
const packageRoot = path.join(root, "package");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
const stateDir = path.join(root, "state");
|
||||
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2026.8.1", type: "module", dependencies: {} }),
|
||||
);
|
||||
fs.writeFileSync(path.join(packageRoot, "openclaw.mjs"), TEST_WORKER_SOURCE, { mode: 0o755 });
|
||||
const distPath = path.join(packageRoot, "dist", "entry.js");
|
||||
fs.writeFileSync(distPath, "export const workerBuild = 1;\n");
|
||||
const installation = await resolveNodeWorkerInstallation({
|
||||
packageRoot,
|
||||
openclawVersion: "2026.8.1",
|
||||
protocolFeatures: [],
|
||||
});
|
||||
const supervisor = createNodeWorkerSupervisor({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
localInstallation: installation,
|
||||
});
|
||||
const localInput = (launchId: string) => {
|
||||
const input = launchInput(workspaceDir, launchId);
|
||||
input.installKind = "local";
|
||||
input.expectedBundleHash = installation.build.bundleHash;
|
||||
input.descriptor.admission.handshake = structuredClone(installation.build);
|
||||
return input;
|
||||
};
|
||||
const stagingRoot = vi.spyOn(fsp, "mkdtemp");
|
||||
|
||||
const first = localInput("local-success");
|
||||
expect(await supervisor.launch(first, TEST_WORKER_ENDPOINT)).toMatchObject({
|
||||
state: "running",
|
||||
});
|
||||
expect(await waitForTerminal(supervisor, first.launchId)).toMatchObject({ state: "completed" });
|
||||
expect(stagingRoot).not.toHaveBeenCalled();
|
||||
|
||||
fs.writeFileSync(distPath, "export const workerBuild = 2;\n");
|
||||
expect(
|
||||
await supervisor.launch(localInput("local-mutated"), TEST_WORKER_ENDPOINT),
|
||||
).toMatchObject({
|
||||
state: "failed",
|
||||
errorText: expect.stringContaining("changed after its build was advertised"),
|
||||
});
|
||||
expect(stagingRoot).toHaveBeenCalledTimes(1);
|
||||
await supervisor.close();
|
||||
});
|
||||
|
||||
it.each(["status", "launch", "cancel", "close"] as const)(
|
||||
"retains an observed terminal outcome when %s reconciliation keeps failing",
|
||||
async (operation) => {
|
||||
@@ -744,7 +692,7 @@ describe("node worker supervisor", () => {
|
||||
const outsideEntry = path.join(root, "outside.mjs");
|
||||
fs.mkdirSync(escapedBundle, { recursive: true });
|
||||
fs.writeFileSync(outsideEntry, TEST_WORKER_SOURCE);
|
||||
fs.symlinkSync(outsideEntry, path.join(escapedBundle, "openclaw.mjs"));
|
||||
fs.symlinkSync(outsideEntry, path.join(escapedBundle, "worker.mjs"));
|
||||
const input = launchInput(workspaceDir, "escaped-entry");
|
||||
input.expectedBundleHash = escapedHash;
|
||||
input.descriptor.admission.handshake.bundleHash = escapedHash;
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
} from "../worker/node-workspace-retain-protocol.js";
|
||||
import { formatWorkerConnectionFailure } from "../worker/worker-connection-contract.js";
|
||||
import type { WorkerConnectionEndpoint } from "../worker/worker-connection-endpoint.js";
|
||||
import type { NodeWorkerInstallation } from "./node-worker-build.js";
|
||||
import { NodeWorkerCapacity } from "./node-worker-capacity.js";
|
||||
import { resolveNodeWorkerEntry } from "./node-worker-entry.js";
|
||||
import { snapshotNodeWorkerEnv } from "./node-worker-environment.js";
|
||||
@@ -90,7 +89,6 @@ type ActiveOwnership = RunningChild | ObservedTerminal;
|
||||
type NodeWorkerSupervisorOptions = {
|
||||
bundleRoot?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
localInstallation?: NodeWorkerInstallation;
|
||||
capacity?: number;
|
||||
capacityWaitMs?: number;
|
||||
onAvailabilityChanged?: (available: boolean) => void;
|
||||
@@ -126,7 +124,6 @@ class NodeWorkerSupervisor {
|
||||
private readonly bundleRoot: string;
|
||||
private readonly store: NodeWorkerLaunchStore;
|
||||
private readonly workerEnv: NodeJS.ProcessEnv;
|
||||
private readonly localInstallation?: NodeWorkerInstallation;
|
||||
private readonly capacity: NodeWorkerCapacity;
|
||||
private readonly workspace: NodeWorkerWorkspaceRuntime;
|
||||
private supervisorIdentity?: NodeWorkerProcessIdentity;
|
||||
@@ -141,7 +138,6 @@ class NodeWorkerSupervisor {
|
||||
);
|
||||
this.store = new NodeWorkerLaunchStore({ env });
|
||||
this.workerEnv = snapshotNodeWorkerEnv(env);
|
||||
this.localInstallation = options.localInstallation;
|
||||
this.workspace =
|
||||
options.workspace ??
|
||||
new NodeWorkerWorkspaceRuntime({ root: this.bundleRoot, env: this.workerEnv });
|
||||
@@ -481,15 +477,13 @@ class NodeWorkerSupervisor {
|
||||
registerSecretValueForRedaction(credential);
|
||||
let adapter: ChildAdapter;
|
||||
try {
|
||||
const entry = await resolveNodeWorkerEntry({
|
||||
const entry = resolveNodeWorkerEntry({
|
||||
bundleRoot: this.bundleRoot,
|
||||
installKind: params.input.installKind,
|
||||
expectedBundleHash: params.input.expectedBundleHash,
|
||||
gatewayNamespace: params.input.gatewayNamespace,
|
||||
...(this.localInstallation ? { localInstallation: this.localInstallation } : {}),
|
||||
});
|
||||
adapter = await createChildAdapter({
|
||||
argv: [process.execPath, entry, "worker", "--internal-worker-ipc"],
|
||||
argv: [process.execPath, entry, "--internal-worker-ipc"],
|
||||
env: this.workerEnv,
|
||||
exactEnv: true,
|
||||
ownedWorker: true,
|
||||
|
||||
@@ -283,10 +283,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
params?.enableAgentRuns === true && config.nodeHost?.agentRuns?.claude?.enabled === true
|
||||
? resolveExecutableTrustPathFromEnv("claude", pathEnv)
|
||||
: null;
|
||||
const workerInstallation =
|
||||
params?.enableWorkerRuns === true && config.nodeHost?.workerRuns?.enabled === true
|
||||
? await resolveNodeWorkerInstallation()
|
||||
: undefined;
|
||||
const workerRunsEnabled =
|
||||
params?.enableWorkerRuns === true && config.nodeHost?.workerRuns?.enabled === true;
|
||||
const workerInstallation = workerRunsEnabled ? await resolveNodeWorkerInstallation() : undefined;
|
||||
const workerRuns = workerInstallation?.build;
|
||||
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({
|
||||
@@ -326,16 +325,15 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
initialInventory,
|
||||
start({ client, onInventoryChanged, onManifestChanged, onRunnerAvailabilityChanged }) {
|
||||
const mcpAbort = new AbortController();
|
||||
const workerWorkspace = workerInstallation
|
||||
const workerWorkspace = workerRunsEnabled
|
||||
? new NodeWorkerWorkspaceRuntime({ env })
|
||||
: undefined;
|
||||
const workerBundleInstaller = workerInstallation
|
||||
const workerBundleInstaller = workerRunsEnabled
|
||||
? new NodeWorkerBundleInstaller({ env })
|
||||
: undefined;
|
||||
const workerSupervisor = workerInstallation
|
||||
const workerSupervisor = workerRunsEnabled
|
||||
? createNodeWorkerSupervisor({
|
||||
env,
|
||||
localInstallation: workerInstallation,
|
||||
onAvailabilityChanged: onRunnerAvailabilityChanged,
|
||||
workspace: workerWorkspace,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js";
|
||||
import { toStringEnv } from "./env.js";
|
||||
|
||||
const FORCE_KILL_WAIT_FALLBACK_MS = 4000;
|
||||
declare const WORKER_DEPLOY_BUILD: boolean;
|
||||
|
||||
type PtyAdapter = SpawnProcessAdapter;
|
||||
|
||||
@@ -23,6 +24,11 @@ export async function createPtyAdapter(params: {
|
||||
rows?: number;
|
||||
name?: string;
|
||||
}): Promise<PtyAdapter> {
|
||||
// Worker deploys are portable JavaScript artifacts; exec falls back to the child adapter
|
||||
// instead of binding the Gateway host's native PTY binary into the bundle.
|
||||
if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) {
|
||||
throw new Error("PTY is unavailable in the portable worker runtime");
|
||||
}
|
||||
const { spawn } = await import("@lydell/node-pty");
|
||||
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
|
||||
const preparedSpawn = prepareOomScoreAdjustedSpawn(params.shell, params.args, { env: baseEnv });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RuntimeTargetIssue } from "../../packages/gateway-protocol/src/schema/environments.js";
|
||||
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
|
||||
import type { ComputerUseCapabilityDescriptor } from "../plugins/computer-use-contract.js";
|
||||
|
||||
@@ -22,6 +23,7 @@ export type NodeListNode = {
|
||||
computerUse?: ComputerUseCapabilityDescriptor;
|
||||
/** Connected node currently advertises full worker session hosting. */
|
||||
sessionHost?: boolean;
|
||||
issues?: readonly RuntimeTargetIssue[];
|
||||
nodePluginTools?: NodePluginToolDescriptor[];
|
||||
permissions?: Record<string, boolean>;
|
||||
approvalState?: "approved" | "pending-approval" | "pending-reapproval" | "unapproved";
|
||||
|
||||
@@ -31,6 +31,8 @@ describe("worker bundle archive", () => {
|
||||
await fs.chmod(path.join(source, "openclaw.mjs"), 0o700);
|
||||
await fs.writeFile(path.join(source, "dist", "worker.js"), "export const worker = true;\n");
|
||||
await fs.chmod(path.join(source, "dist", "worker.js"), 0o600);
|
||||
await fs.writeFile(path.join(source, "dist", "Upper.js"), "export const upper = true;\n");
|
||||
await fs.chmod(path.join(source, "dist", "Upper.js"), 0o600);
|
||||
const sourceManifest = await readWorkerBundleDirectoryManifest({
|
||||
root: source,
|
||||
limits: DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS,
|
||||
@@ -39,6 +41,7 @@ describe("worker bundle archive", () => {
|
||||
await tar.create({ cwd: source, file: archive, gzip: true, noDirRecurse: true }, [
|
||||
"openclaw.mjs",
|
||||
"dist/worker.js",
|
||||
"dist/Upper.js",
|
||||
]);
|
||||
|
||||
await extractWorkerBundleArchive({
|
||||
|
||||
@@ -2,7 +2,11 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { hashWorkerBundleManifest, type WorkerBundleHashEntry } from "./worker-bundle-hash.js";
|
||||
import {
|
||||
compareWorkerBundlePaths,
|
||||
hashWorkerBundleManifest,
|
||||
type WorkerBundleHashEntry,
|
||||
} from "./worker-bundle-hash.js";
|
||||
|
||||
export { DEFAULT_WORKER_BUNDLE_ARCHIVE_LIMITS } from "./worker-bundle-limits.js";
|
||||
|
||||
@@ -121,7 +125,7 @@ export async function readWorkerBundleArchiveManifest(
|
||||
sha256: entry.sha256,
|
||||
};
|
||||
})
|
||||
.toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
.toSorted((left, right) => compareWorkerBundlePaths(left.path, right.path));
|
||||
}
|
||||
|
||||
export async function readWorkerBundleDirectoryManifest(params: {
|
||||
@@ -170,7 +174,7 @@ export async function readWorkerBundleDirectoryManifest(params: {
|
||||
}
|
||||
};
|
||||
await visit(root, "");
|
||||
return entries.toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
return entries.toSorted((left, right) => compareWorkerBundlePaths(left.path, right.path));
|
||||
}
|
||||
|
||||
export async function extractWorkerBundleArchive(params: {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const WORKER_BUNDLE_MANIFEST_VERSION = "openclaw-worker-bundle-v1";
|
||||
export const WORKER_BUNDLE_ENTRY_PATH = "worker.mjs";
|
||||
|
||||
export function compareWorkerBundlePaths(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export type WorkerBundleHashEntry = {
|
||||
path: string;
|
||||
|
||||
@@ -3,23 +3,48 @@
|
||||
* Strict JSON stays the fast path; JSON5 is only the authored/legacy fallback.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { getWorkerDeployJson5 } from "../worker/worker-deploy-runtime-registry.js";
|
||||
|
||||
type Json5Parser = { parse: (value: string) => unknown };
|
||||
let lazyJson5Parser: Json5Parser | undefined;
|
||||
let json5Runtime: Json5Parser | undefined;
|
||||
declare const WORKER_DEPLOY_BUILD: boolean;
|
||||
|
||||
function loadJson5Parser(): Json5Parser {
|
||||
if (lazyJson5Parser) {
|
||||
return lazyJson5Parser;
|
||||
}
|
||||
const loaded = createRequire(import.meta.url)("json5") as Json5Parser | { default?: Json5Parser };
|
||||
const parser = "parse" in loaded ? loaded : loaded.default;
|
||||
if (!parser) {
|
||||
function isJson5Parser(value: unknown): value is Json5Parser {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"parse" in value &&
|
||||
typeof value.parse === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function setJson5Runtime(runtime: unknown): Json5Parser {
|
||||
const parser = isJson5Parser(runtime)
|
||||
? runtime
|
||||
: typeof runtime === "object" && runtime !== null && "default" in runtime
|
||||
? runtime.default
|
||||
: undefined;
|
||||
if (!isJson5Parser(parser)) {
|
||||
throw new Error("json5 parser unavailable");
|
||||
}
|
||||
lazyJson5Parser = parser;
|
||||
json5Runtime = parser;
|
||||
return parser;
|
||||
}
|
||||
|
||||
function loadJson5Parser(): Json5Parser {
|
||||
if (json5Runtime) {
|
||||
return json5Runtime;
|
||||
}
|
||||
const injected = getWorkerDeployJson5();
|
||||
if (injected !== undefined) {
|
||||
return setJson5Runtime(injected);
|
||||
}
|
||||
if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) {
|
||||
throw new Error("worker JSON5 runtime was not registered before use");
|
||||
}
|
||||
return setJson5Runtime(createRequire(import.meta.url)("json5"));
|
||||
}
|
||||
|
||||
/** Parses strict JSON first, then accepts JSON5 syntax such as comments and trailing commas. */
|
||||
export function parseJsonWithJson5Fallback(raw: string, json5?: Json5Parser): unknown {
|
||||
try {
|
||||
|
||||
@@ -82,6 +82,27 @@ describe("worker Browser runtime", () => {
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the build-composed Browser runtime without filesystem discovery", async () => {
|
||||
const createAttachedBrowserToolRuntime = vi.fn().mockResolvedValue({
|
||||
tool: { name: "browser" },
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await createWorkerBrowserToolRuntime({
|
||||
descriptor: {
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
launcherPath: "/usr/local/bin/openclaw-worker-browser",
|
||||
},
|
||||
sessionKey: "worker:session-1",
|
||||
stateDir: "/tmp/worker-state",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
runtime: { createAttachedBrowserToolRuntime },
|
||||
});
|
||||
|
||||
expect(createAttachedBrowserToolRuntime).toHaveBeenCalledOnce();
|
||||
expect(mocks.loadBundledPluginPublicSurfaceModuleSyncCore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces launcher failure without loading another browser route", async () => {
|
||||
const createAttachedBrowserToolRuntime = vi.fn().mockResolvedValue({
|
||||
tool: { name: "browser" },
|
||||
|
||||
@@ -7,13 +7,13 @@ import type { WorkerBrowserLaunchDescriptor } from "./launch-descriptor.js";
|
||||
const WORKER_BROWSER_LAUNCH_TIMEOUT_MS = 30_000;
|
||||
const WORKER_BROWSER_LAUNCH_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
||||
|
||||
type BundledBrowserRuntimeSurface = {
|
||||
export type WorkerBrowserRuntime = {
|
||||
createAttachedBrowserToolRuntime: (params: {
|
||||
cdpUrl: string;
|
||||
ensureAttachTarget: () => Promise<void>;
|
||||
agentSessionKey?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
workspaceDir: string;
|
||||
}) => Promise<{
|
||||
tool: AnyAgentTool;
|
||||
dispose: () => Promise<void>;
|
||||
@@ -30,6 +30,7 @@ type CreateWorkerBrowserToolRuntimeParams = {
|
||||
sessionKey: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
runtime?: WorkerBrowserRuntime;
|
||||
};
|
||||
|
||||
function runWorkerBrowserLauncher(launcherPath: string): Promise<void> {
|
||||
@@ -61,13 +62,13 @@ function runWorkerBrowserLauncher(launcherPath: string): Promise<void> {
|
||||
export async function createWorkerBrowserToolRuntime(
|
||||
params: CreateWorkerBrowserToolRuntimeParams,
|
||||
): Promise<WorkerBrowserToolRuntime> {
|
||||
const browserRuntime = loadBundledPluginPublicSurfaceModuleSyncCore<BundledBrowserRuntimeSurface>(
|
||||
{
|
||||
const browserRuntime =
|
||||
params.runtime ??
|
||||
loadBundledPluginPublicSurfaceModuleSyncCore<WorkerBrowserRuntime>({
|
||||
dirName: "browser",
|
||||
artifactBasename: "runtime-api.js",
|
||||
trackedPluginId: "browser",
|
||||
},
|
||||
);
|
||||
});
|
||||
return await browserRuntime.createAttachedBrowserToolRuntime({
|
||||
cdpUrl: params.descriptor.cdpUrl,
|
||||
ensureAttachTarget: async () => await runWorkerBrowserLauncher(params.descriptor.launcherPath),
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DEFAULT_AGENTS_FILENAME, loadWorkspaceBootstrapFiles } from "../agents/
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { AssistantMessage, AssistantMessageEventStreamLike } from "../llm/types.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
import { createWorkerBrowserToolRuntime } from "./browser-runtime.js";
|
||||
import { createWorkerBrowserToolRuntime, type WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import { createWorkerLiveRuntime } from "./embedded-agent-live.runtime.js";
|
||||
import {
|
||||
createWorkerTranscriptRuntime,
|
||||
@@ -92,6 +92,7 @@ type RunWorkerEmbeddedTurnParams = {
|
||||
inferenceOptions?: WorkerInferenceOptions;
|
||||
allowedToolNames: readonly WorkerToolName[];
|
||||
browser?: WorkerBrowserLaunchDescriptor;
|
||||
browserRuntime?: WorkerBrowserRuntime;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -182,6 +183,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams)
|
||||
sessionKey: params.sessionKey,
|
||||
stateDir: params.stateDir,
|
||||
workspaceDir: params.cwd,
|
||||
...(params.browserRuntime ? { runtime: params.browserRuntime } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const { session } = await (async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ export const NODE_WORKER_CONNECTION_FAILURE_MESSAGE_TYPE = "openclaw-worker-conn
|
||||
export type NodeWorkerLaunchInput = {
|
||||
launchId: string;
|
||||
gatewayNamespace: string;
|
||||
installKind: "local" | "bundle";
|
||||
expectedBundleHash: string;
|
||||
placementGeneration: number;
|
||||
descriptor: WorkerLaunchPlan;
|
||||
@@ -110,7 +109,6 @@ export function parseNodeWorkerLaunchInput(raw?: string | null): NodeWorkerLaunc
|
||||
!hasExactKeys(value, [
|
||||
"launchId",
|
||||
"gatewayNamespace",
|
||||
"installKind",
|
||||
"expectedBundleHash",
|
||||
"placementGeneration",
|
||||
"descriptor",
|
||||
@@ -123,9 +121,6 @@ export function parseNodeWorkerLaunchInput(raw?: string | null): NodeWorkerLaunc
|
||||
if (!GATEWAY_NAMESPACE_PATTERN.test(gatewayNamespace)) {
|
||||
throw new Error("INVALID_REQUEST: gatewayNamespace must be a safe bounded path component");
|
||||
}
|
||||
if (value.installKind !== "local" && value.installKind !== "bundle") {
|
||||
throw new Error("INVALID_REQUEST: installKind must be local or bundle");
|
||||
}
|
||||
if (!isPlanHash(value.expectedBundleHash)) {
|
||||
throw new Error(
|
||||
"INVALID_REQUEST: expectedBundleHash must be 64 lowercase hexadecimal characters",
|
||||
@@ -143,7 +138,6 @@ export function parseNodeWorkerLaunchInput(raw?: string | null): NodeWorkerLaunc
|
||||
return {
|
||||
launchId,
|
||||
gatewayNamespace,
|
||||
installKind: value.installKind,
|
||||
expectedBundleHash: value.expectedBundleHash,
|
||||
placementGeneration: requireNonNegativeInteger(
|
||||
value.placementGeneration,
|
||||
@@ -200,13 +194,12 @@ export function parseNodeWorkerCancelInput(raw?: string | null): NodeWorkerSuper
|
||||
export function nodeWorkerPlanHash(
|
||||
input: Pick<
|
||||
NodeWorkerLaunchInput,
|
||||
"descriptor" | "expectedBundleHash" | "gatewayNamespace" | "installKind" | "placementGeneration"
|
||||
"descriptor" | "expectedBundleHash" | "gatewayNamespace" | "placementGeneration"
|
||||
>,
|
||||
): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
installKind: input.installKind,
|
||||
expectedBundleHash: input.expectedBundleHash,
|
||||
descriptor: input.descriptor,
|
||||
gatewayNamespace: input.gatewayNamespace,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
WORKER_PROTOCOL_FEATURES,
|
||||
WORKER_RPC_SET_VERSION,
|
||||
} from "../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import type { WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import type { WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
||||
import { runWorkerCommand } from "./worker-command.runtime.js";
|
||||
import { runWorkerDescriptor } from "./worker.runtime.js";
|
||||
@@ -105,6 +106,20 @@ describe("worker command lifetime gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the build-composed Browser runtime into the worker boundary", async () => {
|
||||
const output = new PassThrough();
|
||||
const browserRuntime = {
|
||||
createAttachedBrowserToolRuntime: vi.fn(),
|
||||
} as unknown as WorkerBrowserRuntime;
|
||||
|
||||
await runWorkerCommand({ input: commandInput(), output, browserRuntime });
|
||||
|
||||
expect(runWorkerDescriptor).toHaveBeenCalledWith(
|
||||
descriptor,
|
||||
expect.objectContaining({ browserRuntime }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not enter the worker runtime before the explicit start message", async () => {
|
||||
const output = new PassThrough();
|
||||
const lifetime = lifetimeHarness();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
import { WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES } from "../../packages/gateway-protocol/src/schema/worker-inference.js";
|
||||
import type { WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import { parseWorkerLaunchDescriptor, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
||||
import { runWorkerDescriptor } from "./worker.runtime.js";
|
||||
|
||||
@@ -7,6 +8,7 @@ type RunWorkerCommandOptions = {
|
||||
input: Readable;
|
||||
lifetime?: WorkerCommandLifetime;
|
||||
output: Writable;
|
||||
browserRuntime?: WorkerBrowserRuntime;
|
||||
};
|
||||
|
||||
export type WorkerCommandLifetime = {
|
||||
@@ -82,6 +84,7 @@ export async function runWorkerCommand(options: RunWorkerCommandOptions): Promis
|
||||
...(options.lifetime
|
||||
? { onConnectionFailure: options.lifetime.reportConnectionFailure }
|
||||
: {}),
|
||||
...(options.browserRuntime ? { browserRuntime: options.browserRuntime } : {}),
|
||||
});
|
||||
const encoded = `${JSON.stringify(result)}\n`;
|
||||
options.output.write(encoded);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
|
||||
const workerDeployBrowserRuntime: WorkerBrowserRuntime = {
|
||||
async createAttachedBrowserToolRuntime() {
|
||||
throw new Error("worker deploy Browser runtime was not composed by the build");
|
||||
},
|
||||
};
|
||||
|
||||
export default workerDeployBrowserRuntime;
|
||||
@@ -0,0 +1,14 @@
|
||||
import "./worker-deploy-runtime.js";
|
||||
import workerDeployBrowserRuntime from "./worker-deploy-browser-runtime.js";
|
||||
import { runWorkerProcess } from "./worker-process.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== "--internal-worker-ipc")) {
|
||||
throw new Error("worker deploy entry received unsupported arguments");
|
||||
}
|
||||
const internalWorkerIpc = args[0] === "--internal-worker-ipc";
|
||||
|
||||
await runWorkerProcess({
|
||||
internalWorkerIpc,
|
||||
browserRuntime: workerDeployBrowserRuntime,
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const highlightJsRuntime: unknown;
|
||||
export default highlightJsRuntime;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Build-only bridge: keep highlight.js DOM declarations out of the core TypeScript program.
|
||||
import highlightJsRuntime from "highlight.js";
|
||||
|
||||
export default highlightJsRuntime;
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const json5Runtime: unknown;
|
||||
export default json5Runtime;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Build-only bridge: bundle JSON5 without changing the normal lazy CLI import path.
|
||||
import * as json5Runtime from "json5";
|
||||
|
||||
export default json5Runtime;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ResolveSecureTempRoot } from "../infra/secure-temp-root.js";
|
||||
|
||||
type WorkerDeployRuntime = {
|
||||
highlightJs?: unknown;
|
||||
json5?: unknown;
|
||||
resolveSecureTempRoot?: ResolveSecureTempRoot;
|
||||
};
|
||||
|
||||
const runtime: WorkerDeployRuntime = {};
|
||||
|
||||
export function setWorkerDeployRuntime(next: Required<WorkerDeployRuntime>): void {
|
||||
runtime.highlightJs = next.highlightJs;
|
||||
runtime.json5 = next.json5;
|
||||
runtime.resolveSecureTempRoot = next.resolveSecureTempRoot;
|
||||
}
|
||||
|
||||
export function getWorkerDeployHighlightJs(): unknown {
|
||||
return runtime.highlightJs;
|
||||
}
|
||||
|
||||
export function getWorkerDeployJson5(): unknown {
|
||||
return runtime.json5;
|
||||
}
|
||||
|
||||
export function getWorkerDeploySecureTempRoot(): ResolveSecureTempRoot | undefined {
|
||||
return runtime.resolveSecureTempRoot;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { resolveSecureTempRoot } from "../infra/secure-temp-root.js";
|
||||
import highlightJsRuntime from "./worker-deploy-highlight-runtime.mjs";
|
||||
import json5Runtime from "./worker-deploy-json5-runtime.mjs";
|
||||
import { setWorkerDeployRuntime } from "./worker-deploy-runtime-registry.js";
|
||||
|
||||
setWorkerDeployRuntime({
|
||||
highlightJs: highlightJsRuntime,
|
||||
json5: json5Runtime,
|
||||
resolveSecureTempRoot,
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { signalProcessTree } from "../process/kill-tree.js";
|
||||
import type { WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import {
|
||||
NODE_WORKER_CONNECTION_FAILURE_MESSAGE_TYPE,
|
||||
type NodeWorkerConnectionFailureMessage,
|
||||
} from "./node-supervisor-protocol.js";
|
||||
import { runWorkerCommand, type WorkerCommandLifetime } from "./worker-command.runtime.js";
|
||||
|
||||
const WORKER_START_MESSAGE_TYPE = "openclaw-worker-start-v1";
|
||||
|
||||
function isWorkerStartMessage(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 1 &&
|
||||
(value as { type?: unknown }).type === WORKER_START_MESSAGE_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
function createWorkerIpcLifetime(): WorkerCommandLifetime {
|
||||
if (!process.connected || !process.channel || typeof process.send !== "function") {
|
||||
throw new Error("internal worker IPC mode requires a connected Node IPC channel");
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
let disposed = false;
|
||||
let started = false;
|
||||
let settled = false;
|
||||
let resolveStarted!: (started: boolean) => void;
|
||||
let rejectStarted!: (error: Error) => void;
|
||||
const startedPromise = new Promise<boolean>((resolve, reject) => {
|
||||
resolveStarted = resolve;
|
||||
rejectStarted = reject;
|
||||
});
|
||||
const rejectOrAbort = (error: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
rejectStarted(error);
|
||||
return;
|
||||
}
|
||||
abortController.abort(error);
|
||||
};
|
||||
const onMessage = (message: unknown) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (!isWorkerStartMessage(message) || settled) {
|
||||
rejectOrAbort(new Error("invalid internal worker IPC start message"));
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
settled = true;
|
||||
resolveStarted(true);
|
||||
};
|
||||
const onDisconnect = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolveStarted(false);
|
||||
return;
|
||||
}
|
||||
if (started) {
|
||||
abortController.abort(new Error("worker supervisor lifetime ended"));
|
||||
}
|
||||
};
|
||||
process.on("message", onMessage);
|
||||
process.once("disconnect", onDisconnect);
|
||||
return {
|
||||
started: startedPromise,
|
||||
signal: abortController.signal,
|
||||
reportConnectionFailure: (cause) => {
|
||||
if (disposed || !process.connected || typeof process.send !== "function") {
|
||||
return;
|
||||
}
|
||||
const message: NodeWorkerConnectionFailureMessage = {
|
||||
type: NODE_WORKER_CONNECTION_FAILURE_MESSAGE_TYPE,
|
||||
cause: cause ?? null,
|
||||
};
|
||||
try {
|
||||
process.send(message, () => {});
|
||||
} catch {
|
||||
// The disconnect handler owns worker shutdown when the supervisor is gone.
|
||||
}
|
||||
},
|
||||
terminateOwnedTree: () => {
|
||||
signalProcessTree(process.pid, "SIGKILL", {
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
process.off("message", onMessage);
|
||||
process.off("disconnect", onDisconnect);
|
||||
if (process.connected) {
|
||||
try {
|
||||
process.disconnect?.();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ERR_IPC_DISCONNECTED") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs the worker-only process entry without loading the general CLI command tree. */
|
||||
export async function runWorkerProcess(
|
||||
options: {
|
||||
internalWorkerIpc?: boolean;
|
||||
browserRuntime?: WorkerBrowserRuntime;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
await runWorkerCommand({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
...(options.internalWorkerIpc ? { lifetime: createWorkerIpcLifetime() } : {}),
|
||||
...(options.browserRuntime ? { browserRuntime: options.browserRuntime } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { chmod, mkdtemp, realpath, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import { buildWorkerConnectParams, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
||||
import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js";
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export async function runWorkerDescriptor(
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onConnectionFailure?: (cause: string | undefined) => void;
|
||||
browserRuntime?: WorkerBrowserRuntime;
|
||||
} = {},
|
||||
): Promise<WorkerRuntimeResult> {
|
||||
const workspaceDir = await assertWorkspaceDirectory(descriptor.assignment.workspaceDir);
|
||||
@@ -146,6 +148,7 @@ export async function runWorkerDescriptor(
|
||||
inferenceOptions: descriptor.assignment.inferenceOptions,
|
||||
allowedToolNames: descriptor.assignment.toolAuthority.allowedToolNames,
|
||||
...(descriptor.assignment.browser ? { browser: descriptor.assignment.browser } : {}),
|
||||
...(options.browserRuntime ? { browserRuntime: options.browserRuntime } : {}),
|
||||
inference: { stream },
|
||||
transcript: {
|
||||
commit: async (messages) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WORKER_PROTOCOL_FEATURES } from "../../../../packages/gateway-protocol/
|
||||
import type { DeviceIdentity } from "../../../../src/infra/device-identity.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../../../../src/infra/device-identity.js";
|
||||
import {
|
||||
NODE_WORKER_BUNDLE_INSTALL_COMMAND,
|
||||
NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND,
|
||||
NODE_WORKER_SUPERVISOR_STATUS_COMMAND,
|
||||
NODE_WORKER_WORKSPACE_EXEC_COMMAND,
|
||||
@@ -23,10 +24,8 @@ import {
|
||||
NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE,
|
||||
} from "../../../../src/infra/node-runner-inventory.js";
|
||||
import { handleInvoke, type NodeInvokeRequestPayload } from "../../../../src/node-host/invoke.js";
|
||||
import {
|
||||
resolveNodeWorkerInstallation,
|
||||
type NodeWorkerInstallation,
|
||||
} from "../../../../src/node-host/node-worker-build.js";
|
||||
import { resolveNodeWorkerInstallation } from "../../../../src/node-host/node-worker-build.js";
|
||||
import { NodeWorkerBundleInstaller } from "../../../../src/node-host/node-worker-bundle-installer.js";
|
||||
import { createNodeWorkerSupervisor } from "../../../../src/node-host/node-worker-supervisor.js";
|
||||
import { NodeWorkerWorkspaceRuntime } from "../../../../src/node-host/node-worker-workspace.js";
|
||||
import { VERSION } from "../../../../src/version.js";
|
||||
@@ -41,9 +40,10 @@ import {
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SESSION_KEY = "agent:qa:node-worker-launch-wire";
|
||||
const NODE_DISPLAY_NAME = "QA local-install worker node";
|
||||
const NODE_DISPLAY_NAME = "QA Gateway-bundle worker node";
|
||||
const TEST_TIMEOUT_MS = PROOF_TIMEOUT_MS + 60_000;
|
||||
|
||||
type NodeWorkerInstallation = Awaited<ReturnType<typeof resolveNodeWorkerInstallation>>;
|
||||
type Gateway = Awaited<ReturnType<typeof startQaGatewayChild>>;
|
||||
type GatewayEvent = { event: string; payload?: unknown };
|
||||
type NodeRead = {
|
||||
@@ -133,18 +133,12 @@ async function createPublishedWorkspace(root: string) {
|
||||
async function createSourceWorkerInstallation(root: string): Promise<NodeWorkerInstallation> {
|
||||
const packageRoot = path.join(root, "local-install");
|
||||
const repoRoot = process.cwd();
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await Promise.all([
|
||||
fs.copyFile(path.join(repoRoot, "openclaw.mjs"), path.join(packageRoot, "openclaw.mjs")),
|
||||
fs.copyFile(path.join(repoRoot, "package.json"), path.join(packageRoot, "package.json")),
|
||||
fs.cp(path.join(repoRoot, "dist"), path.join(packageRoot, "dist"), { recursive: true }),
|
||||
]);
|
||||
await fs.chmod(path.join(packageRoot, "openclaw.mjs"), 0o700);
|
||||
await fs.symlink(
|
||||
path.join(repoRoot, "node_modules"),
|
||||
path.join(packageRoot, "node_modules"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
await fs.mkdir(path.join(packageRoot, "dist", "worker"), { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(repoRoot, "dist", "worker", "worker.mjs"),
|
||||
path.join(packageRoot, "dist", "worker", "worker.mjs"),
|
||||
);
|
||||
await fs.chmod(path.join(packageRoot, "dist", "worker", "worker.mjs"), 0o700);
|
||||
return await resolveNodeWorkerInstallation({
|
||||
packageRoot,
|
||||
openclawVersion: VERSION,
|
||||
@@ -354,10 +348,8 @@ describe("node worker launch wire", () => {
|
||||
OPENCLAW_STATE_DIR: path.join(root, "node-state"),
|
||||
};
|
||||
await fs.mkdir(nodeEnv.HOME, { recursive: true });
|
||||
const supervisor = createNodeWorkerSupervisor({
|
||||
env: nodeEnv,
|
||||
localInstallation: installation,
|
||||
});
|
||||
const supervisor = createNodeWorkerSupervisor({ env: nodeEnv });
|
||||
const bundleInstaller = new NodeWorkerBundleInstaller({ env: nodeEnv });
|
||||
const workspace = new NodeWorkerWorkspaceRuntime({
|
||||
root: path.join(root, "node-workspaces"),
|
||||
env: nodeEnv,
|
||||
@@ -405,6 +397,7 @@ describe("node worker launch wire", () => {
|
||||
launchId = (JSON.parse(frame.paramsJSON) as { launchId?: string }).launchId;
|
||||
}
|
||||
const task = handleInvoke(frame, receiver, { current: async () => [] }, undefined, {
|
||||
workerBundleInstaller: bundleInstaller,
|
||||
workerSupervisor: supervisor,
|
||||
workerWorkspace: workspace,
|
||||
gatewayUrl: gateway!.wsUrl,
|
||||
@@ -502,6 +495,7 @@ describe("node worker launch wire", () => {
|
||||
await Promise.all([...invokeTasks]);
|
||||
expect(invokeErrors).toEqual([]);
|
||||
expect(reconnected).toBe(true);
|
||||
expect(commands).toContain(NODE_WORKER_BUNDLE_INSTALL_COMMAND);
|
||||
expect(commands).toContain(NODE_WORKER_WORKSPACE_EXEC_COMMAND);
|
||||
expect(commands).toContain(NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND);
|
||||
expect(commands).toContain(NODE_WORKER_SUPERVISOR_STATUS_COMMAND);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectCliBootstrapExternalImportErrors,
|
||||
collectGatewayRunChunkBudgetErrors,
|
||||
collectWorkerDeployArtifactErrors,
|
||||
listStaticImportSpecifiers,
|
||||
} from "../../scripts/check-cli-bootstrap-imports.mts";
|
||||
|
||||
@@ -127,4 +128,46 @@ describe("check-cli-bootstrap-imports", () => {
|
||||
`Gateway run chunk dist/run-gateway.js is ${gatewayRunChunkBytes} bytes, above budget 50 bytes.`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts one self-contained worker executable with builtin imports", () => {
|
||||
const root = makeTempRoot();
|
||||
writeFixture(
|
||||
root,
|
||||
"dist/worker/worker.mjs",
|
||||
'import fs from "node:fs";\nexport const worker = Boolean(fs);\n',
|
||||
);
|
||||
|
||||
expect(collectWorkerDeployArtifactErrors({ rootDir: root })).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects worker package imports and dependency manifests", () => {
|
||||
const root = makeTempRoot();
|
||||
writeFixture(
|
||||
root,
|
||||
"dist/worker/worker.mjs",
|
||||
[
|
||||
'import "left-pad";',
|
||||
'await import("./lazy.mjs");',
|
||||
'__require("json5");',
|
||||
'createRequire(import.meta.url)("../../package.json");',
|
||||
'moduleNamespace.createRequire(import.meta.url)("@openclaw/fs-safe/temp");',
|
||||
].join("\n"),
|
||||
);
|
||||
writeFixture(root, "dist/worker/lazy.mjs", "export {};\n");
|
||||
writeFixture(
|
||||
root,
|
||||
"dist/worker/package.json",
|
||||
`${JSON.stringify({ scripts: { postinstall: "node prepare.js" } })}\n`,
|
||||
);
|
||||
|
||||
expect(collectWorkerDeployArtifactErrors({ rootDir: root })).toEqual([
|
||||
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "../../package.json" instead of bundling it.',
|
||||
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "./lazy.mjs" instead of bundling it.',
|
||||
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "@openclaw/fs-safe/temp" instead of bundling it.',
|
||||
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "json5" instead of bundling it.',
|
||||
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "left-pad" instead of bundling it.',
|
||||
"Worker deploy artifact emits unstaged runtime asset dist/worker/lazy.mjs.",
|
||||
"Worker deploy artifact must not contain a dependency manifest or lifecycle scripts.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TSDOWN_UNIFIED_CONFIG_GROUP,
|
||||
TSDOWN_UNIFIED_DTS_CONFIG_GROUPS,
|
||||
} from "../../scripts/lib/tsdown-config-groups.mts";
|
||||
import { WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID } from "../../scripts/lib/worker-deploy-build-plugin.mts";
|
||||
import config from "../../tsdown.config.ts";
|
||||
|
||||
const configs = Array.isArray(config) ? config : [config];
|
||||
@@ -15,6 +16,16 @@ const configs = Array.isArray(config) ? config : [config];
|
||||
type TsdownConfig = (typeof configs)[number];
|
||||
type OutExtensions = NonNullable<TsdownConfig["outExtensions"]>;
|
||||
|
||||
function isWorkerDeployConfig(config: TsdownConfig): boolean {
|
||||
const entry = config.entry;
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(entry as Record<string, unknown>)["worker/worker"] === "src/worker/worker-deploy-entry.ts"
|
||||
);
|
||||
}
|
||||
|
||||
describe("tsdown config", () => {
|
||||
it.each(["tsdown.config.ts", "tsdown.ai.config.ts"])(
|
||||
"keeps %s free of runtime imports from tsdown",
|
||||
@@ -85,8 +96,60 @@ describe("tsdown config", () => {
|
||||
expect(privateDeclarationSources).toContain("src/plugin-sdk/tts-runtime.ts");
|
||||
});
|
||||
|
||||
it("builds one worker-only executable with every package dependency bundled", () => {
|
||||
const workerConfig = configs.find(isWorkerDeployConfig);
|
||||
expect(workerConfig?.entry).toEqual({
|
||||
"worker/worker": "src/worker/worker-deploy-entry.ts",
|
||||
});
|
||||
expect(workerConfig?.dts).toBe(false);
|
||||
const packageVersion = (
|
||||
JSON.parse(fs.readFileSync("package.json", "utf8")) as {
|
||||
version: string;
|
||||
}
|
||||
).version;
|
||||
expect(workerConfig?.define).toEqual({
|
||||
WORKER_DEPLOY_BUILD: "true",
|
||||
WORKER_DEPLOY_VERSION: JSON.stringify(packageVersion),
|
||||
});
|
||||
expect(workerConfig?.alias).toMatchObject({
|
||||
bufferutil: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"chromium-bidi/lib/cjs/bidiMapper/BidiMapper": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"chromium-bidi/lib/cjs/cdp/CdpConnection": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"electron/index.js": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
fsevents: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
kerberos: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"utf-8-validate": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
});
|
||||
expect(workerConfig?.outDir).toBe("dist");
|
||||
expect(workerConfig?.shims).toBe(true);
|
||||
expect(workerConfig?.plugins).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "openclaw:worker-deploy" })]),
|
||||
);
|
||||
expect(workerConfig?.outputOptions).toMatchObject({
|
||||
codeSplitting: false,
|
||||
assetFileNames: "worker/[name][extname]",
|
||||
});
|
||||
expect(workerConfig?.deps?.onlyBundle).toBe(false);
|
||||
expect(workerConfig?.deps?.alwaysBundle).toBeTypeOf("function");
|
||||
const alwaysBundle = workerConfig?.deps?.alwaysBundle;
|
||||
if (typeof alwaysBundle !== "function") {
|
||||
throw new Error("worker deploy config must define dependency bundling");
|
||||
}
|
||||
expect(alwaysBundle("json5", undefined)).toBe(true);
|
||||
expect(alwaysBundle("node:fs", undefined)).toBe(false);
|
||||
|
||||
const context = {
|
||||
format: "es",
|
||||
options: {},
|
||||
pkgType: "module",
|
||||
} as Parameters<OutExtensions>[0];
|
||||
expect(workerConfig?.outExtensions?.(context)).toEqual({ js: ".mjs", dts: ".d.ts" });
|
||||
});
|
||||
|
||||
it("keeps node package artifacts on the declared js and dts extensions", () => {
|
||||
const nodePackageConfigs = configs.filter((entry) => entry.fixedExtension === false);
|
||||
const nodePackageConfigs = configs.filter(
|
||||
(entry) => entry.fixedExtension === false && !isWorkerDeployConfig(entry),
|
||||
);
|
||||
expect(nodePackageConfigs).not.toHaveLength(0);
|
||||
|
||||
const context = {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createWorkerDeployBuildPlugin,
|
||||
WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
} from "../../scripts/lib/worker-deploy-build-plugin.mts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const fail = (message: string): never => {
|
||||
throw new Error(message);
|
||||
};
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
describe("worker deploy build plugin", () => {
|
||||
it("replaces optional host-native modules with a failing virtual module", () => {
|
||||
const plugin = createWorkerDeployBuildPlugin();
|
||||
|
||||
expect(plugin.load(WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID)).toContain(
|
||||
"optional host-native dependency unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("composes the bundled Browser runtime only in the deploy build", () => {
|
||||
const bridgePath = path.resolve("src/worker/worker-deploy-browser-runtime.ts");
|
||||
const source = fs.readFileSync(bridgePath, "utf8");
|
||||
const plugin = createWorkerDeployBuildPlugin();
|
||||
|
||||
const transformed = plugin.transform.call({ error: fail }, source, bridgePath);
|
||||
|
||||
expect(transformed).toContain(
|
||||
'import { createAttachedBrowserToolRuntime } from "../../extensions/browser/runtime-api.js";',
|
||||
);
|
||||
expect(transformed).toContain("export default { createAttachedBrowserToolRuntime };");
|
||||
expect(transformed).not.toContain("was not composed by the build");
|
||||
});
|
||||
|
||||
it("inlines Playwright package identity without a runtime manifest read", () => {
|
||||
const coreBundlePath = path.resolve("node_modules/playwright-core/lib/coreBundle.js");
|
||||
const source = fs.readFileSync(coreBundlePath, "utf8");
|
||||
const plugin = createWorkerDeployBuildPlugin();
|
||||
|
||||
const transformed = plugin.transform.call({ error: fail }, source, coreBundlePath);
|
||||
|
||||
expect(transformed).toContain('packageJSON = {"name":"playwright-core","version":"1.62.1"};');
|
||||
expect(transformed).not.toContain(
|
||||
'packageJSON = require(import_path9.default.join(packageRoot, "package.json"));',
|
||||
);
|
||||
expect(transformed).toContain(
|
||||
'registry = new Registry({"comment":"Do not edit this file, use utils/roll_browser.js"',
|
||||
);
|
||||
expect(transformed).not.toContain(
|
||||
'registry = new Registry(require(import_path20.default.join(packageRoot, "browsers.json")));',
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the canonical dependency path behind a pnpm-style symlink", () => {
|
||||
const sourceRoot = path.resolve("node_modules/playwright-core");
|
||||
const source = fs.readFileSync(path.join(sourceRoot, "lib/coreBundle.js"), "utf8");
|
||||
const tempRoot = tempDirs.make("openclaw-worker-build-plugin-");
|
||||
const linkedRoot = path.join(tempRoot, "node_modules", "playwright-core");
|
||||
fs.mkdirSync(path.dirname(linkedRoot), { recursive: true });
|
||||
fs.symlinkSync(sourceRoot, linkedRoot, process.platform === "win32" ? "junction" : "dir");
|
||||
const plugin = createWorkerDeployBuildPlugin(tempRoot);
|
||||
const resolvedId = fs.realpathSync(path.join(linkedRoot, "lib/coreBundle.js"));
|
||||
|
||||
const transformed = plugin.transform.call({ error: fail }, source, resolvedId);
|
||||
|
||||
expect(transformed).toContain('packageJSON = {"name":"playwright-core","version":"1.62.1"};');
|
||||
});
|
||||
|
||||
it("fails closed when the dependency-owned bootstrap shape changes", () => {
|
||||
const coreBundlePath = path.resolve("node_modules/playwright-core/lib/coreBundle.js");
|
||||
const plugin = createWorkerDeployBuildPlugin();
|
||||
|
||||
expect(() =>
|
||||
plugin.transform.call({ error: fail }, "changed upstream source", coreBundlePath),
|
||||
).toThrow("playwright-core package bootstrap changed");
|
||||
});
|
||||
});
|
||||
+50
-4
@@ -1,5 +1,6 @@
|
||||
// tsdown config defines package build entrypoints and output options.
|
||||
import fs from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import type { UserConfig } from "tsdown";
|
||||
import {
|
||||
@@ -22,6 +23,10 @@ import {
|
||||
TSDOWN_UNIFIED_DTS_CONFIG_GROUPS,
|
||||
} from "./scripts/lib/tsdown-config-groups.mts";
|
||||
import { tsdownPackageOutputRoot } from "./scripts/lib/tsdown-output-roots.mts";
|
||||
import {
|
||||
createWorkerDeployBuildPlugin,
|
||||
WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
} from "./scripts/lib/worker-deploy-build-plugin.mts";
|
||||
|
||||
type InputOptionsFactory = Extract<NonNullable<UserConfig["inputOptions"]>, Function>;
|
||||
type InputOptionsArg = InputOptionsFactory extends (
|
||||
@@ -48,6 +53,9 @@ type ExternalOptionFunction = (
|
||||
const env = {
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
const workerDeployVersion = (
|
||||
JSON.parse(fs.readFileSync("package.json", "utf8")) as { version: string }
|
||||
).version;
|
||||
const OUTPUT_SOURCE_MAPS = process.env.OUTPUT_SOURCE_MAPS === "1";
|
||||
const RUN_NODE_SKIP_DTS_BUILD = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1";
|
||||
const TSDOWN_DECLARATIONS = !RUN_NODE_SKIP_DTS_BUILD;
|
||||
@@ -87,7 +95,10 @@ function matchesExternalOption(
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildInputOptions(options: InputOptionsArg): InputOptionsReturn {
|
||||
function buildInputOptions(
|
||||
options: InputOptionsArg,
|
||||
build?: { bundleAllDependencies?: boolean },
|
||||
): InputOptionsReturn {
|
||||
if (process.env.OPENCLAW_BUILD_VERBOSE === "1") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -127,7 +138,7 @@ function buildInputOptions(options: InputOptionsArg): InputOptionsReturn {
|
||||
...options,
|
||||
external(id: string, parentId: string | undefined, isResolved: boolean) {
|
||||
return (
|
||||
shouldNeverBundleDependency(id) ||
|
||||
(!build?.bundleAllDependencies && shouldNeverBundleDependency(id)) ||
|
||||
matchesExternalOption(previousExternal, id, parentId, isResolved)
|
||||
);
|
||||
},
|
||||
@@ -156,7 +167,41 @@ function nodeBuildConfig(
|
||||
outExtensions: () => ({ js: ".js", dts: ".d.ts" }),
|
||||
fixedExtension: false,
|
||||
sourcemap: OUTPUT_SOURCE_MAPS,
|
||||
inputOptions: buildInputOptions,
|
||||
inputOptions: (options) => buildInputOptions(options),
|
||||
};
|
||||
}
|
||||
|
||||
function workerDeployBuildConfig(): UserConfig {
|
||||
return {
|
||||
name: TSDOWN_UNIFIED_CONFIG_GROUP,
|
||||
entry: { "worker/worker": "src/worker/worker-deploy-entry.ts" },
|
||||
outDir: "dist",
|
||||
dts: false,
|
||||
env,
|
||||
define: {
|
||||
WORKER_DEPLOY_BUILD: "true",
|
||||
WORKER_DEPLOY_VERSION: JSON.stringify(workerDeployVersion),
|
||||
},
|
||||
alias: {
|
||||
bufferutil: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"chromium-bidi/lib/cjs/bidiMapper/BidiMapper": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"chromium-bidi/lib/cjs/cdp/CdpConnection": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"electron/index.js": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
fsevents: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
kerberos: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
"utf-8-validate": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
||||
},
|
||||
deps: {
|
||||
alwaysBundle: (id) => !isBuiltin(id),
|
||||
onlyBundle: false,
|
||||
},
|
||||
fixedExtension: false,
|
||||
outExtensions: () => ({ js: ".mjs", dts: ".d.ts" }),
|
||||
outputOptions: { codeSplitting: false, assetFileNames: "worker/[name][extname]" },
|
||||
plugins: [createStateSchemaInlinePlugin(), createWorkerDeployBuildPlugin()],
|
||||
shims: true,
|
||||
sourcemap: OUTPUT_SOURCE_MAPS,
|
||||
inputOptions: (options) => buildInputOptions(options, { bundleAllDependencies: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +214,7 @@ function nodeWorkspacePackageBuildConfig(packageDir: string, config: UserConfig
|
||||
name: config.name ?? TSDOWN_PACKAGE_CONFIG_GROUP,
|
||||
outDir: config.outDir ?? tsdownPackageOutputRoot(packageDir),
|
||||
sourcemap: OUTPUT_SOURCE_MAPS,
|
||||
inputOptions: buildInputOptions,
|
||||
inputOptions: (options) => buildInputOptions(options),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -679,6 +724,7 @@ const configs = [
|
||||
},
|
||||
false,
|
||||
),
|
||||
workerDeployBuildConfig(),
|
||||
...(TSDOWN_DECLARATIONS
|
||||
? buildUnifiedDeclarationPartitions(unifiedDistEntries).map(({ name, sources }) =>
|
||||
nodeBuildConfig(
|
||||
|
||||
@@ -28,6 +28,20 @@ suite.define(() => {
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
{
|
||||
nodeId: "outdated-mac",
|
||||
displayName: "Outdated build Mac",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
issues: [
|
||||
{
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: "offline-rich",
|
||||
displayName: "Offline rich device",
|
||||
@@ -73,6 +87,23 @@ suite.define(() => {
|
||||
"custom.unknown",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "node:outdated-mac",
|
||||
type: "node",
|
||||
status: "available",
|
||||
platform: "darwin",
|
||||
sessionHost: false,
|
||||
trust: "persistent",
|
||||
capabilities: ["system.run"],
|
||||
issues: [
|
||||
{
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "node:offline-rich",
|
||||
type: "node",
|
||||
@@ -112,6 +143,15 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => device.locator(".new-session-page__menu-fact").allTextContents())
|
||||
.toEqual(["macOS", "Camera", "Screen capture", "Voice"]);
|
||||
const outdated = place.locator('[data-value="node:outdated-mac"]');
|
||||
expect(await outdated.count()).toBe(1);
|
||||
expect(await outdated.isDisabled()).toBe(true);
|
||||
await expect
|
||||
.poll(() => outdated.locator(".new-session-page__menu-fact").allTextContents())
|
||||
.toEqual([
|
||||
"Update required: run openclaw update, then reconnect. For a headless node, run openclaw node restart.",
|
||||
]);
|
||||
expect(await outdated.getAttribute("title")).toContain("openclaw update");
|
||||
await expect
|
||||
.poll(() =>
|
||||
place
|
||||
|
||||
@@ -39,6 +39,12 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
it("refreshes destinations from gateway events while the picker stays open", async () => {
|
||||
const updateIssue = {
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
};
|
||||
const lifecycleNowMs = Date.now();
|
||||
const disconnectedAtMs = lifecycleNowMs - 2 * 60_000;
|
||||
const connectedAtMs = disconnectedAtMs - 3 * 60_000;
|
||||
@@ -70,6 +76,7 @@ suite.define(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
"sessions.create": { key: "agent:main:picker-refresh" },
|
||||
"environments.list": {
|
||||
environments: [
|
||||
{ id: "gateway", type: "local", status: "available" },
|
||||
@@ -241,6 +248,51 @@ suite.define(() => {
|
||||
await place.getByRole("button", { name: "Cloud · aws" }).waitFor();
|
||||
expect(await place.getAttribute("open")).not.toBeNull();
|
||||
await captureUiProof(page, "picker-after-live-regroup.png");
|
||||
|
||||
await newMac.click();
|
||||
await expect.poll(() => trigger.getAttribute("data-exec-node")).toBe("new-mac");
|
||||
const outdatedNodeRequests = (await gateway.getRequests("node.list")).length;
|
||||
await gateway.setMethodResponse("node.list", {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "existing-mac",
|
||||
displayName: "Existing Mac",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
{
|
||||
nodeId: "new-mac",
|
||||
displayName: "New Mac",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
issues: [updateIssue],
|
||||
},
|
||||
],
|
||||
});
|
||||
await gateway.setMethodResponse("environments.list", {
|
||||
environments: [
|
||||
{ id: "gateway", type: "local", status: "available" },
|
||||
{ id: "node:existing-mac", type: "node", status: "available" },
|
||||
{
|
||||
id: "node:new-mac",
|
||||
type: "node",
|
||||
status: "available",
|
||||
issues: [updateIssue],
|
||||
},
|
||||
],
|
||||
profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
|
||||
});
|
||||
await gateway.emitGatewayEvent("node.runnerInventory.changed", { nodeId: "new-mac" });
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("node.list")).length)
|
||||
.toBeGreaterThan(outdatedNodeRequests);
|
||||
await expect.poll(() => trigger.getAttribute("data-exec-node")).toBeNull();
|
||||
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
|
||||
|
||||
await page.locator(".new-session-page__message").fill("use an eligible place");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).not.toHaveProperty("execNode");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
@@ -739,6 +739,8 @@ export const en: TranslationMap = {
|
||||
neverConnected: "Never connected",
|
||||
offlineFor: "Offline for {duration}",
|
||||
lastSeen: "Last seen {time}",
|
||||
nodeUpdateRequired:
|
||||
"Update required: run {updateCommand}, then reconnect. For a headless node, run {restartCommand}.",
|
||||
capabilityCamera: "Camera",
|
||||
capabilityLocation: "Location",
|
||||
capabilityTalk: "Talk",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readDraftCloudProfiles, readDraftEnvironments, readDraftNodes } from "./discovery.ts";
|
||||
import {
|
||||
isDraftNodeSessionEligible,
|
||||
readDraftCloudProfiles,
|
||||
readDraftEnvironments,
|
||||
readDraftNodes,
|
||||
} from "./discovery.ts";
|
||||
|
||||
describe("readDraftNodes", () => {
|
||||
it("ignores non-record array entries without throwing", () => {
|
||||
@@ -52,6 +57,59 @@ describe("readDraftNodes", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps only the exact structured update-required issue", () => {
|
||||
const issue = {
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
};
|
||||
expect(
|
||||
readDraftNodes([
|
||||
{
|
||||
nodeId: "outdated",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
issues: [issue, { ...issue, headlessReconnectCommand: "legacy restart" }],
|
||||
},
|
||||
])[0]?.issues,
|
||||
).toEqual([issue]);
|
||||
expect(
|
||||
readDraftEnvironments([{ id: "node:outdated", type: "node", issues: [issue] }])[0]?.issues,
|
||||
).toEqual([issue]);
|
||||
});
|
||||
|
||||
it("uses capability, connection, and update state for session eligibility", () => {
|
||||
const nodes = readDraftNodes([
|
||||
{ nodeId: "eligible", connected: true, commands: ["system.run"] },
|
||||
{ nodeId: "offline", connected: false, commands: ["system.run"] },
|
||||
{ nodeId: "no-exec", connected: true, commands: ["fs.listDir"] },
|
||||
{
|
||||
nodeId: "outdated",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
issues: [
|
||||
{
|
||||
code: "update-required",
|
||||
action: "update-and-reconnect",
|
||||
updateCommand: "openclaw update",
|
||||
headlessReconnectCommand: "openclaw node restart",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const eligibility = Object.fromEntries(
|
||||
nodes.map((node) => [node.nodeId, isDraftNodeSessionEligible(node)]),
|
||||
);
|
||||
|
||||
expect(eligibility).toEqual({
|
||||
eligible: true,
|
||||
"no-exec": false,
|
||||
offline: false,
|
||||
outdated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("readDraftCloudProfiles", () => {
|
||||
it("keeps closed profile summaries in stable order", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeArrayBackedTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { RuntimeTargetIssue } from "../../../../packages/gateway-protocol/src/schema/environments.ts";
|
||||
|
||||
export type DraftBranches = {
|
||||
repoRoot: string;
|
||||
@@ -26,6 +27,7 @@ export type DraftNode = {
|
||||
connected: boolean;
|
||||
canExec: boolean;
|
||||
canBrowse: boolean;
|
||||
issues?: RuntimeTargetIssue[];
|
||||
};
|
||||
|
||||
export type DraftCloudProfile = {
|
||||
@@ -45,16 +47,40 @@ export type DraftEnvironment = {
|
||||
lastSeenReason?: string;
|
||||
trust?: "persistent" | "disposable";
|
||||
capabilities?: string[];
|
||||
issues?: RuntimeTargetIssue[];
|
||||
};
|
||||
|
||||
export type BrowserTarget = { nodeId: string; label: string };
|
||||
|
||||
export function draftNodeUpdateIssue(node: DraftNode): RuntimeTargetIssue | undefined {
|
||||
return node.issues?.find((issue) => issue.code === "update-required");
|
||||
}
|
||||
|
||||
export function isDraftNodeSessionEligible(node: DraftNode): boolean {
|
||||
return node.canExec && node.connected && draftNodeUpdateIssue(node) === undefined;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readRuntimeTargetIssues(value: unknown): RuntimeTargetIssue[] | undefined {
|
||||
const issues = (Array.isArray(value) ? value : []).flatMap<RuntimeTargetIssue>((raw) => {
|
||||
if (!isRecord(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.code === "update-required" &&
|
||||
raw.action === "update-and-reconnect" &&
|
||||
raw.updateCommand === "openclaw update" &&
|
||||
raw.headlessReconnectCommand === "openclaw node restart"
|
||||
? [raw as RuntimeTargetIssue]
|
||||
: [];
|
||||
});
|
||||
return issues.length > 0 ? issues : undefined;
|
||||
}
|
||||
|
||||
export function readDraftNodes(value: unknown): DraftNode[] {
|
||||
const rawNodes = Array.isArray(value) ? value : [];
|
||||
return rawNodes
|
||||
@@ -71,6 +97,7 @@ export function readDraftNodes(value: unknown): DraftNode[] {
|
||||
remoteIp?: unknown;
|
||||
connected?: unknown;
|
||||
commands?: unknown;
|
||||
issues?: unknown;
|
||||
};
|
||||
const nodeId = normalizeOptionalString(node.nodeId);
|
||||
const commands = Array.isArray(node.commands)
|
||||
@@ -81,6 +108,7 @@ export function readDraftNodes(value: unknown): DraftNode[] {
|
||||
}
|
||||
const connected = node.connected === true;
|
||||
const canExec = commands.includes("system.run");
|
||||
const issues = readRuntimeTargetIssues(node.issues);
|
||||
return [
|
||||
{
|
||||
nodeId,
|
||||
@@ -92,6 +120,7 @@ export function readDraftNodes(value: unknown): DraftNode[] {
|
||||
connected,
|
||||
canExec,
|
||||
canBrowse: connected && canExec && commands.includes("fs.listDir"),
|
||||
...(issues ? { issues } : {}),
|
||||
},
|
||||
];
|
||||
})
|
||||
@@ -140,6 +169,7 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] {
|
||||
lastSeenReason?: unknown;
|
||||
trust?: unknown;
|
||||
capabilities?: unknown;
|
||||
issues?: unknown;
|
||||
};
|
||||
const id = normalizeOptionalString(environment.id);
|
||||
const type = normalizeOptionalString(environment.type);
|
||||
@@ -156,6 +186,7 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] {
|
||||
const lastDisconnectedAtMs = normalizeTimestamp(environment.lastDisconnectedAtMs);
|
||||
const lastSeenAtMs = normalizeTimestamp(environment.lastSeenAtMs);
|
||||
const lastSeenReason = normalizeOptionalString(environment.lastSeenReason);
|
||||
const issues = readRuntimeTargetIssues(environment.issues);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
@@ -170,6 +201,7 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] {
|
||||
...(lastSeenReason ? { lastSeenReason } : {}),
|
||||
...(trust ? { trust } : {}),
|
||||
...(capabilities ? { capabilities } : {}),
|
||||
...(issues ? { issues } : {}),
|
||||
},
|
||||
];
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user