mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(gateway): observe paired node desktops (#122724)
* feat(gateway): stream paired node desktops * chore(protocol): refresh desktop observe model * fix(gateway): preserve desktop stream boundaries * fix(gateway): keep desktop streams lifetime-bound * fix(gateway): harden node desktop stream lifecycle * fix(gateway): stabilize node desktop lifecycle setup * chore(plugin-sdk): refresh API baselines
This commit is contained in:
committed by
GitHub
parent
c631b1a45e
commit
0c824f09d5
@@ -2304,6 +2304,7 @@ public struct DesktopObserveResult: Codable, Sendable {
|
||||
public let control: Bool
|
||||
public let vncpassword: String?
|
||||
public let auth: String?
|
||||
public let preauthenticated: Bool?
|
||||
|
||||
public init(
|
||||
transport: String,
|
||||
@@ -2311,7 +2312,8 @@ public struct DesktopObserveResult: Codable, Sendable {
|
||||
expiresatms: Int,
|
||||
control: Bool,
|
||||
vncpassword: String? = nil,
|
||||
auth: String? = nil)
|
||||
auth: String? = nil,
|
||||
preauthenticated: Bool? = nil)
|
||||
{
|
||||
self.transport = transport
|
||||
self.wspath = wspath
|
||||
@@ -2319,6 +2321,7 @@ public struct DesktopObserveResult: Codable, Sendable {
|
||||
self.control = control
|
||||
self.vncpassword = vncpassword
|
||||
self.auth = auth
|
||||
self.preauthenticated = preauthenticated
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2328,6 +2331,7 @@ public struct DesktopObserveResult: Codable, Sendable {
|
||||
case control
|
||||
case vncpassword = "vncPassword"
|
||||
case auth
|
||||
case preauthenticated
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"0afe8b5e500dce4237ca4b5023ab225cfd31a9897e0d4bfdb2341886df035952","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
{"contentHash":"4cbdbf71af85d9f128c7508ff8fc731e26d6534a005cf198ee65a29900c77340","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"4580a38b9e871c7d448c90cd247673929caaa7a298877d96d8e3f7e7c970b27a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"23d191b0194005162c3004d7094de2c2ab3130519cc1dd8e93b9fd3b7de1887a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"b84b53ca4792a410eb488ae4fb303e362c3c84d8eb72e37cf9c922183792a7cd","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"1a06845da584661ae0964b92fb78a6e30b5b49eb76e8607d7d27e22b9cea06e5","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"cd582aff7d7278ddc71a3c99bd4f8b46e2083411f02096e8649e74b39ab001ea","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
{"contentHash":"3a12bd6e0a49b35124afb94210e13e08d009c3879f28e5d397f7c3155155ef8d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"abb22832153a23cd5187f92f2cdee6d81889d61697fae04c8647eb3370a1aa93","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"986d193f0ee65375bba05e796533e619ef5d5861d3cb4b76d3233ff94f60ae26","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"a82dcfdcc661b2567ba2ba631d589887d5ca29cfe1d8600696fe0db859f4969f","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"f34d5a25428beca66e891f24c358befc2ffa900ae5efe66770039e95b6d554f8","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"034d00199482a5490cb8202aa2ff6c5acd438486e8ab616b5f76674af7fb78c4","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
{"contentHash":"c16c7681de92566c0c886aae20c229422185d65a6a1be627e97fe4152b9a0cd9","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"2efff507ffe87196b554e29043eadc0ba17a32864f3ccd1864d4200add9d8f87","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"c8683be7c78ece135932acd07c4b5c8e840177d1fe16f9da298202287dd4b305","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"31bfbc294becf37f8ee037a0f8f50c69e4d04e097ff9bea16089115ee93b80df","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"cf626c5629096ad218de506f7e35d22806efcbd4eacccc3d84df13aa726f7f33","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"bb371c8a354a150acf264357e3cbcb755efc6f42d9a2143e5a9254c3bc3c2aa8","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"6b238d77a12894d47525c52b8e564656292178054b6f7dfcba4c5f4ba721c124","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"07dbe53008bb2487dd63f28db4d81424cfb67498e4508aae6d86667e78fe1855","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
{"contentHash":"449257831591673a7f5bb4a2664cf5931cdc7ba9baa380f4b25513fb11a66c13","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"5ccd913160d86827e36459bf6c6e88a169306dd24e913b85e288cbbf2ea12157","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"63740457d088b93b3f42f5f5991ca566c03c7024a471bfcee9f4bae3dd1f609e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d9ae989750b9f69072b5767cdf8995bde3f431da315e031cb80f35d2052a3917","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"73b7833128355b975c6084eeb195b2d2278982fb42e00253ad1d26249cb6bd51","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"dce833138906cea4ce741068911c2e4818ebc37693edb2d2883e54eb7b25778e","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"0b4f0f3996add880e30ae03f78f1ee8220d67eece0b85c89204e9e384f242d8f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"0010661448f741237afcb47b75bdc2178d962e4e5751dfc4d26fdb1c4c487997","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
{"contentHash":"1cd3f740a75bcc58e73253165e13bef74f7e4472d59d2cb0c5f3a14a5944a4e6","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"a392d8bbb2ababa55a16ac115beff0ed0b790723c8e6c69c0a46d44c6984ed4e","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"9f785c65c9d357a29d1449fe6ac3c4bbafe9cd638298485f209ba3547bf118ee","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"a9f8b45ec3684fdb607c50fc47c41f62255c9ac0eae282b42441df58400fce31","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"d99313fa91889a8eb69c5331f28dac834d133c7dadd2b6fafae69975afdeb56e","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -619,6 +619,70 @@ or logs. `openclaw doctor` can offer an explicitly confirmed `sudo launchctl`
|
||||
repair when Screen Sharing is off; enabling the macOS system service may expose
|
||||
it on other network interfaces according to macOS Sharing settings.
|
||||
|
||||
### Paired node desktops
|
||||
|
||||
A paired macOS, Windows, or Linux node can expose its own desktop in the same
|
||||
Control UI Desktop panel. This path is intentionally off by default and always
|
||||
uses an existing node-local RFB server on `127.0.0.1`; the Gateway never asks a
|
||||
node to connect to a caller-selected host or port.
|
||||
|
||||
On the node machine, enable the desktop source and configure attach mode:
|
||||
|
||||
```json5
|
||||
{
|
||||
desktop: {
|
||||
host: {
|
||||
enabled: true,
|
||||
port: 5900,
|
||||
// passwordFile: "/path/to/vnc-password.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Restart the node host after changing this config. `managed: true` is a Gateway
|
||||
host feature and does not start a managed desktop inside a node host; paired
|
||||
nodes must already have a loopback RFB server.
|
||||
|
||||
On the Gateway, explicitly arm the dangerous command and restart:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
commands: {
|
||||
allow: ["desktop.stream"],
|
||||
// deny: ["desktop.stream"], // deny always wins
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The node reconnect advertises `desktop.stream` as a pairing-surface upgrade.
|
||||
Inspect `openclaw nodes pending`, then approve the new request with
|
||||
`openclaw nodes approve <requestId>`. The node appears in the Desktop picker
|
||||
only while it is connected and the effective approved command remains allowed.
|
||||
|
||||
For VncAuth, `desktop.host.passwordFile` stays on the node and is delivered only
|
||||
to the Gateway's authenticated relay. Without a password file, the Control UI
|
||||
prompts for the VNC password. macOS ARD credentials are always prompted per
|
||||
observation. The Gateway completes ARD or VNC authentication before exposing a
|
||||
no-auth RFB handshake to the browser, so credentials are not returned in URLs,
|
||||
logs, or RPC results.
|
||||
|
||||
Desktop bytes use a dedicated outbound binary WebSocket from the node. The
|
||||
normal node invoke remains only as the cancellable lifecycle handle and never
|
||||
carries framebuffer data. Reconnecting or changing the node's pairing
|
||||
generation closes active relays. To disarm the feature, remove
|
||||
`desktop.stream` from `commands.allow` or add it to `commands.deny`, restart the
|
||||
Gateway, and reconnect the node.
|
||||
|
||||
If the node is missing from the picker, verify all four gates: the node-local
|
||||
desktop config, the loopback RFB listener, the approved pairing update, and the
|
||||
Gateway allow/deny policy. After changing any of them, restart the affected
|
||||
Gateway or node host and check `openclaw nodes pending` again.
|
||||
|
||||
---
|
||||
|
||||
## Gateway
|
||||
@@ -781,7 +845,7 @@ it on other network interfaces according to macOS Sharing settings.
|
||||
- `gateway.nodes.pairing.autoApproveLocal`: silently approves pairing, role upgrades, and scope upgrades from trusted local connections (default: `true`). Set `false` to require explicit approval for every device; metadata-only reconnect refreshes remain automatic.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades.
|
||||
- `gateway.nodes.pairing.sshVerify`: SSH-verified auto-approval for first-time node device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only on an exact `openclaw node identity` device-key match. Same eligibility floor as `autoApproveCidrs`; probes are limited to private/CGNAT source addresses unless `cidrs` overrides them. Set `false` to disable, or `{ user, identity, timeoutMs, cidrs }` to tune. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default).
|
||||
- `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. `commands.allow` is the one-time persistent enable for classified commands such as `camera.snap`, `camera.clip`, `screen.record`, `health.summary`, `sms.search`, and `sms.send`; `commands.deny` removes a command even if a platform default or explicit allow would otherwise include it. Computer and mobile UI control instead rely on default-off node-local enablement plus pairing. iOS Health permission, Android SMS permission, and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot.
|
||||
- `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. `commands.allow` is the one-time persistent enable for classified commands such as `camera.snap`, `camera.clip`, `desktop.stream`, `screen.record`, `health.summary`, `sms.search`, and `sms.send`; `commands.deny` removes a command even if a platform default or explicit allow would otherwise include it. Computer and mobile UI control instead rely on default-off node-local enablement plus pairing. iOS Health permission, Android SMS permission, and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot.
|
||||
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
|
||||
- `gateway.tools.allow`: remove tool names from the default HTTP deny list for
|
||||
owner/admin callers. This does not upgrade identity-bearing `operator.write`
|
||||
|
||||
+3
-3
@@ -484,7 +484,7 @@ These rows describe the Gateway policy ceiling, not the commands implemented by
|
||||
|
||||
Desktop host commands (`system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `mcp.tools.call.v1`, and `screen.snapshot` on macOS/Windows/Linux) are not part of the static platform-default table above. They become available once the operator approves a pairing request that declares them, after which the node's approved command set carries them forward on reconnect.
|
||||
|
||||
Dangerous or privacy-heavy commands require a one-time persistent opt-in with `gateway.nodes.commands.allow`, even if a node declares them: `camera.snap`, `camera.clip`, `camera.ptz.control`, `screen.record`, `contacts.add`, `calendar.add`, `reminders.add`, `health.summary`, `sms.send`, `sms.search`. `gateway.nodes.commands.deny` always wins over defaults and extra allowlist entries. See [HealthKit summaries](/platforms/ios-healthkit) for the iPhone consent gate and [Computer use](/nodes/computer-use) for the local enablement, pairing, capability, and tool-policy gates around desktop input.
|
||||
Dangerous or privacy-heavy commands require a one-time persistent opt-in with `gateway.nodes.commands.allow`, even if a node declares them: `camera.snap`, `camera.clip`, `camera.ptz.control`, `desktop.stream`, `screen.record`, `contacts.add`, `calendar.add`, `reminders.add`, `health.summary`, `sms.send`, `sms.search`. `gateway.nodes.commands.deny` always wins over defaults and extra allowlist entries. See [Paired node desktops](/gateway/configuration-reference#paired-node-desktops), [HealthKit summaries](/platforms/ios-healthkit), and [Computer use](/nodes/computer-use) for the local enablement, pairing, capability, and tool-policy gates around desktop access.
|
||||
|
||||
Plugin-owned node commands can add a Gateway node-invoke policy. That policy runs after the allowlist check and before forwarding to the node, so raw `node.invoke`, CLI helpers, and dedicated agent tools share the same plugin permission boundary. Dangerous plugin node commands still require explicit `gateway.nodes.commands.allow` opt-in.
|
||||
|
||||
@@ -511,9 +511,9 @@ Node-related settings live under `gateway.nodes` and `tools.exec`:
|
||||
pluginTools: {
|
||||
enabled: true,
|
||||
},
|
||||
// Persistently enable dangerous/privacy-heavy node commands (camera.snap, etc.).
|
||||
// Persistently enable dangerous/privacy-heavy node commands.
|
||||
commands: {
|
||||
allow: ["camera.snap", "screen.record"],
|
||||
allow: ["camera.snap", "desktop.stream", "screen.record"],
|
||||
// Block exact command names even if defaults or commands.allow include them.
|
||||
deny: ["camera.clip"],
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "../index.js";
|
||||
|
||||
describe("desktop protocol schemas", () => {
|
||||
it("accepts host and environment observe sources while rejecting unknown source kinds", () => {
|
||||
it("accepts host, environment, and node observe sources", () => {
|
||||
expect(validateDesktopObserveParams({ source: { kind: "host" }, control: true })).toBe(true);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
@@ -20,7 +20,21 @@ describe("desktop protocol schemas", () => {
|
||||
source: { kind: "environment", environmentId: "worker:one" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateDesktopObserveParams({ source: { kind: "node", nodeId: "one" } })).toBe(false);
|
||||
expect(validateDesktopObserveParams({ source: { kind: "node", nodeId: "one" } })).toBe(true);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "node", nodeId: "one" },
|
||||
credentials: { username: "operator", password: "secret" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "node", nodeId: "one" },
|
||||
credentials: { password: "secret" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateDesktopObserveParams({ source: { kind: "node", nodeId: "" } })).toBe(false);
|
||||
expect(validateDesktopObserveParams({ source: { kind: "future" } })).toBe(false);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "environment", environmentId: "worker:one" },
|
||||
@@ -55,6 +69,7 @@ describe("desktop protocol schemas", () => {
|
||||
expiresAtMs: 1,
|
||||
control: false,
|
||||
auth: "ard-account",
|
||||
preauthenticated: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NonEmptyString } from "./primitives.js";
|
||||
export const DesktopSourceSchema = Type.Union([
|
||||
closedObject({ kind: Type.Literal("host") }),
|
||||
closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }),
|
||||
closedObject({ kind: Type.Literal("node"), nodeId: NonEmptyString }),
|
||||
]);
|
||||
|
||||
const DesktopObserveCredentialsSchema = closedObject({
|
||||
@@ -26,6 +27,11 @@ export const DesktopObserveParamsSchema = Type.Union([
|
||||
source: closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }),
|
||||
control: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
closedObject({
|
||||
source: closedObject({ kind: Type.Literal("node"), nodeId: NonEmptyString }),
|
||||
control: Type.Optional(Type.Boolean()),
|
||||
credentials: Type.Optional(DesktopObserveCredentialsSchema),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const DesktopObserveResultSchema = closedObject({
|
||||
@@ -36,6 +42,8 @@ export const DesktopObserveResultSchema = closedObject({
|
||||
vncPassword: Type.Optional(NonEmptyString),
|
||||
// Auth drives credential prompting without coupling clients to RFB security numbers.
|
||||
auth: Type.Optional(Type.String({ enum: ["none", "vnc-password", "ard-account"] })),
|
||||
// Gateway-side pre-auth keeps credentials out of the browser RFB client.
|
||||
preauthenticated: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const DesktopLaunchParamsSchema = closedObject({
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { connectRfbAttachment } from "./attachment.js";
|
||||
import { createDesktopSessionRegistry } from "./session-registry.js";
|
||||
|
||||
const servers: net.Server[] = [];
|
||||
const sockets: net.Socket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
for (const socket of sockets.splice(0)) {
|
||||
socket.destroy();
|
||||
}
|
||||
@@ -45,4 +48,103 @@ describe("RFB attachments", () => {
|
||||
|
||||
await expect(accepted).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not claim a stream that closed before observer redemption", async () => {
|
||||
const registry = createDesktopSessionRegistry();
|
||||
await registry.acquire({
|
||||
sourceKey: "node:one",
|
||||
ownerEpoch: 1,
|
||||
start: async () => ({
|
||||
attachment: { kind: "tcp", host: "127.0.0.1", port: 5900 },
|
||||
}),
|
||||
});
|
||||
const stream = new PassThrough();
|
||||
const reservation = registry.reserveObserver("node:one", 1);
|
||||
if (!reservation) {
|
||||
throw new Error("expected observer reservation");
|
||||
}
|
||||
const attachment = registry.publishStream({
|
||||
sourceKey: "node:one",
|
||||
ownerEpoch: 1,
|
||||
stream,
|
||||
reservation,
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new Error("expected stream attachment");
|
||||
}
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
stream.once("close", () => resolve());
|
||||
});
|
||||
stream.destroy();
|
||||
await closed;
|
||||
|
||||
expect(registry.claimStream(attachment)).toBeUndefined();
|
||||
await registry.stopAll();
|
||||
});
|
||||
|
||||
it("refreshes the cleanup deadline when an idle stream session is reactivated", async () => {
|
||||
vi.useFakeTimers();
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const registry = createDesktopSessionRegistry({ lingerMs: 25 });
|
||||
await registry.activate({ sourceKey: "node:one", ownerEpoch: 1, teardown });
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await registry.activate({ sourceKey: "node:one", ownerEpoch: 1 });
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
expect(teardown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds pending observer reservations before streams are started", async () => {
|
||||
const registry = createDesktopSessionRegistry();
|
||||
await registry.activate({ sourceKey: "node:one", ownerEpoch: 1 });
|
||||
const reservations = Array.from({ length: 8 }, () => registry.reserveObserver("node:one", 1));
|
||||
expect(reservations.every(Boolean)).toBe(true);
|
||||
expect(registry.reserveObserver("node:one", 1)).toBeUndefined();
|
||||
|
||||
reservations[0]?.release();
|
||||
expect(registry.reserveObserver("node:one", 1)).toBeDefined();
|
||||
await registry.stopAll();
|
||||
});
|
||||
|
||||
it("keeps a reserved observer session alive and rearms cleanup on release", async () => {
|
||||
vi.useFakeTimers();
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const registry = createDesktopSessionRegistry({ lingerMs: 25 });
|
||||
await registry.activate({ sourceKey: "node:one", ownerEpoch: 1, teardown });
|
||||
const reservation = registry.reserveObserver("node:one", 1);
|
||||
if (!reservation) {
|
||||
throw new Error("expected observer reservation");
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
|
||||
reservation.release();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(teardown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not linger-stop a reservation when another observer disconnects", async () => {
|
||||
vi.useFakeTimers();
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const registry = createDesktopSessionRegistry({ lingerMs: 25 });
|
||||
await registry.activate({ sourceKey: "node:one", ownerEpoch: 1, teardown });
|
||||
const observer = registry.attachObserver("node:one", {
|
||||
ownerEpoch: 1,
|
||||
control: false,
|
||||
close: () => {},
|
||||
});
|
||||
const reservation = registry.reserveObserver("node:one", 1);
|
||||
if (!observer || !reservation) {
|
||||
throw new Error("expected observer and reservation");
|
||||
}
|
||||
observer.release();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
|
||||
reservation.release();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(teardown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
|
||||
export type RfbAttachment =
|
||||
| { kind: "unix-socket"; socketPath: string }
|
||||
| { kind: "tcp"; host: "127.0.0.1"; port: number };
|
||||
|
||||
/** One already-connected RFB transport published by a remote desktop source. */
|
||||
type RfbStreamAttachment = { kind: "stream"; streamId: string };
|
||||
|
||||
export type DesktopRfbAttachment = RfbAttachment | RfbStreamAttachment;
|
||||
|
||||
export function connectRfbAttachment(attachment: RfbAttachment): net.Socket {
|
||||
return attachment.kind === "unix-socket"
|
||||
? net.connect(attachment.socketPath)
|
||||
: net.connect(attachment.port, attachment.host);
|
||||
}
|
||||
|
||||
export type ConnectedRfbStream = Duplex;
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
export class HostDesktopCredentialsRequiredError extends Error {
|
||||
readonly auth = "ard-account" as const;
|
||||
export class DesktopCredentialsRequiredError extends Error {
|
||||
readonly detailCode = "DESKTOP_CREDENTIALS_REQUIRED" as const;
|
||||
|
||||
constructor(
|
||||
readonly auth: "vnc-password" | "ard-account",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DesktopCredentialsRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class HostDesktopCredentialsRequiredError extends DesktopCredentialsRequiredError {
|
||||
declare readonly auth: "ard-account";
|
||||
|
||||
constructor() {
|
||||
super("macOS account credentials are required to observe Screen Sharing");
|
||||
super("ard-account", "macOS account credentials are required to observe Screen Sharing");
|
||||
this.name = "HostDesktopCredentialsRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isHostDesktopCredentialsRequiredError(
|
||||
export function isDesktopCredentialsRequiredError(
|
||||
error: unknown,
|
||||
): error is HostDesktopCredentialsRequiredError {
|
||||
return error instanceof HostDesktopCredentialsRequiredError;
|
||||
): error is DesktopCredentialsRequiredError {
|
||||
return error instanceof DesktopCredentialsRequiredError;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import { invokeNodeDesktopStream } from "../../node-host/desktop-stream-command.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../../shared/node-desktop-stream.js";
|
||||
import type { NodeRegistry } from "../node-registry.js";
|
||||
import { createNodeDesktopService } from "./node-source.js";
|
||||
import { createNodeDesktopStreamBroker } from "./node-stream-broker.js";
|
||||
import { handleDesktopObserveUpgrade } from "./observe-bridge.js";
|
||||
import { createDesktopSessionRegistry } from "./session-registry.js";
|
||||
|
||||
const VERSION = Buffer.from("RFB 003.008\n", "ascii");
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
||||
});
|
||||
|
||||
class SocketReader {
|
||||
private buffered = Buffer.alloc(0);
|
||||
private readonly waiters = new Set<() => void>();
|
||||
|
||||
constructor(socket: net.Socket) {
|
||||
socket.on("data", (chunk) => {
|
||||
this.buffered = Buffer.concat([
|
||||
this.buffered,
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
]);
|
||||
for (const waiter of this.waiters) {
|
||||
waiter();
|
||||
}
|
||||
this.waiters.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async readExactly(length: number): Promise<Buffer> {
|
||||
while (this.buffered.length < length) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.add(resolve);
|
||||
});
|
||||
}
|
||||
const value = this.buffered.subarray(0, length);
|
||||
this.buffered = this.buffered.subarray(length);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketReader {
|
||||
private readonly chunks: Buffer[] = [];
|
||||
private readonly waiters: Array<(chunk: Buffer) => void> = [];
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
ws.on("message", (data: RawData) => {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) {
|
||||
waiter(chunk);
|
||||
} else {
|
||||
this.chunks.push(chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async next(): Promise<Buffer> {
|
||||
return (
|
||||
this.chunks.shift() ??
|
||||
(await new Promise<Buffer>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("paired node desktop observe integration", () => {
|
||||
it("relays pixels through the node attach socket and drops view-only input", async () => {
|
||||
const rfbPeers = new Set<net.Socket>();
|
||||
let connectionCount = 0;
|
||||
let completedStreams = 0;
|
||||
let resolveRfbScript!: () => void;
|
||||
let rejectRfbScript!: (error: Error) => void;
|
||||
const rfbScript = new Promise<void>((resolve, reject) => {
|
||||
resolveRfbScript = resolve;
|
||||
rejectRfbScript = reject;
|
||||
});
|
||||
const rfbServer = net.createServer((socket) => {
|
||||
rfbPeers.add(socket);
|
||||
socket.once("close", () => rfbPeers.delete(socket));
|
||||
connectionCount += 1;
|
||||
const connectionIndex = connectionCount;
|
||||
const reader = new SocketReader(socket);
|
||||
void (async () => {
|
||||
try {
|
||||
socket.write(VERSION);
|
||||
expect(await reader.readExactly(VERSION.length)).toEqual(VERSION);
|
||||
socket.write(Buffer.from([1, 2]));
|
||||
if (connectionIndex % 2 === 1) {
|
||||
return;
|
||||
}
|
||||
expect(await reader.readExactly(1)).toEqual(Buffer.from([2]));
|
||||
socket.write(Buffer.alloc(16, 7));
|
||||
expect(await reader.readExactly(16)).toHaveLength(16);
|
||||
socket.write(Buffer.alloc(4));
|
||||
|
||||
expect(await reader.readExactly(1)).toEqual(Buffer.from([1]));
|
||||
socket.write(Buffer.from("pixel-update", "ascii"));
|
||||
const framebufferRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]);
|
||||
expect(await reader.readExactly(framebufferRequest.length)).toEqual(framebufferRequest);
|
||||
completedStreams += 1;
|
||||
if (completedStreams === 2) {
|
||||
resolveRfbScript();
|
||||
}
|
||||
} catch (error) {
|
||||
rejectRfbScript(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
})();
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
rfbServer.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const rfbAddress = rfbServer.address();
|
||||
if (!rfbAddress || typeof rfbAddress === "string") {
|
||||
throw new Error("expected RFB address");
|
||||
}
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
for (const peer of rfbPeers) {
|
||||
peer.destroy();
|
||||
}
|
||||
rfbServer.close(() => resolve());
|
||||
}),
|
||||
);
|
||||
|
||||
const desktopRegistry = createDesktopSessionRegistry({ lingerMs: 10 });
|
||||
const streamBroker = createNodeDesktopStreamBroker();
|
||||
cleanups.push(async () => desktopRegistry.stopAll());
|
||||
const httpServer = http.createServer();
|
||||
let gatewayUrl = "";
|
||||
const nodeSession = {
|
||||
nodeId: "node-1",
|
||||
connId: "conn-1",
|
||||
pairingGeneration: "generation-1",
|
||||
platform: "linux",
|
||||
deviceFamily: "Linux",
|
||||
commands: [NODE_DESKTOP_STREAM_COMMAND],
|
||||
};
|
||||
const nodeRegistry = {
|
||||
get: () => nodeSession,
|
||||
getForPairingGeneration: (_nodeId: string, generation: string) =>
|
||||
generation === nodeSession.pairingGeneration ? nodeSession : undefined,
|
||||
isConnectionCurrentPairingState: async (connId: string) => connId === nodeSession.connId,
|
||||
invoke: async (request: {
|
||||
params?: unknown;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (chunk: string) => void;
|
||||
}) => {
|
||||
try {
|
||||
await invokeNodeDesktopStream({
|
||||
paramsJSON: JSON.stringify({
|
||||
...(request.params as { ticket: string; attachPath: string }),
|
||||
}),
|
||||
gatewayUrl,
|
||||
config: { enabled: true, port: rfbAddress.port },
|
||||
signal: request.signal ?? new AbortController().signal,
|
||||
emitStatus: async (status) => request.onProgress?.(status),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
};
|
||||
}
|
||||
},
|
||||
} as unknown as NodeRegistry;
|
||||
httpServer.on("upgrade", (req, socket, head) => {
|
||||
void (async () => {
|
||||
if (await streamBroker.handleUpgrade(req, socket, head, nodeRegistry)) {
|
||||
return;
|
||||
}
|
||||
handleDesktopObserveUpgrade(req, socket, head, { registry: desktopRegistry });
|
||||
})();
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const httpAddress = httpServer.address();
|
||||
if (!httpAddress || typeof httpAddress === "string") {
|
||||
throw new Error("expected Gateway address");
|
||||
}
|
||||
gatewayUrl = `ws://127.0.0.1:${httpAddress.port}`;
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
}),
|
||||
);
|
||||
|
||||
const service = createNodeDesktopService({
|
||||
getConfig: () => ({
|
||||
gateway: { nodes: { commands: { allow: [NODE_DESKTOP_STREAM_COMMAND] } } },
|
||||
}),
|
||||
nodeRegistry,
|
||||
desktopRegistry,
|
||||
streamBroker,
|
||||
});
|
||||
const observed = await service.observe({
|
||||
nodeId: nodeSession.nodeId,
|
||||
control: false,
|
||||
credentials: { password: "memory-only-password" },
|
||||
});
|
||||
expect(observed.auth).toBe("vnc-password");
|
||||
|
||||
const ws = new WebSocket(`${gatewayUrl}${observed.wsPath}`);
|
||||
const browser = new WebSocketReader(ws);
|
||||
cleanups.push(async () => ws.terminate());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
expect(await browser.next()).toEqual(VERSION);
|
||||
ws.send(Buffer.concat([VERSION, Buffer.from([1, 0])]));
|
||||
expect(await browser.next()).toEqual(Buffer.from([1, 1]));
|
||||
expect(await browser.next()).toEqual(Buffer.alloc(4));
|
||||
expect(await browser.next()).toEqual(Buffer.from("pixel-update", "ascii"));
|
||||
|
||||
const keyEvent = Buffer.from([4, 1, 0, 0, 0, 0, 0, 65]);
|
||||
const framebufferRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]);
|
||||
ws.send(Buffer.concat([keyEvent, framebufferRequest]));
|
||||
|
||||
const secondObserved = await service.observe({
|
||||
nodeId: nodeSession.nodeId,
|
||||
control: false,
|
||||
credentials: { password: "memory-only-password" },
|
||||
});
|
||||
const secondWs = new WebSocket(`${gatewayUrl}${secondObserved.wsPath}`);
|
||||
const secondBrowser = new WebSocketReader(secondWs);
|
||||
cleanups.push(async () => secondWs.terminate());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
secondWs.once("open", resolve);
|
||||
secondWs.once("error", reject);
|
||||
});
|
||||
expect(await secondBrowser.next()).toEqual(VERSION);
|
||||
secondWs.send(Buffer.concat([VERSION, Buffer.from([1, 0])]));
|
||||
expect(await secondBrowser.next()).toEqual(Buffer.from([1, 1]));
|
||||
expect(await secondBrowser.next()).toEqual(Buffer.alloc(4));
|
||||
expect(await secondBrowser.next()).toEqual(Buffer.from("pixel-update", "ascii"));
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
secondWs.send(Buffer.concat([keyEvent, framebufferRequest]));
|
||||
|
||||
await expect(rfbScript).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => expect(connectionCount).toBe(4));
|
||||
const firstClosed = new Promise<void>((resolve) => {
|
||||
ws.once("close", () => resolve());
|
||||
});
|
||||
const secondClosed = new Promise<void>((resolve) => {
|
||||
secondWs.once("close", () => resolve());
|
||||
});
|
||||
|
||||
await service.stopNode(nodeSession.nodeId);
|
||||
|
||||
await Promise.all([firstClosed, secondClosed]);
|
||||
await vi.waitFor(() => expect(rfbPeers.size).toBe(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NodeDesktopService } from "./node-source.js";
|
||||
|
||||
export const NODE_DESKTOP_SERVICE_CONTEXT = Symbol("openclaw.nodeDesktopService");
|
||||
|
||||
type NodeDesktopServiceContext = {
|
||||
[NODE_DESKTOP_SERVICE_CONTEXT]?: NodeDesktopService;
|
||||
};
|
||||
|
||||
export function getNodeDesktopService(context: object): NodeDesktopService | undefined {
|
||||
return (context as NodeDesktopServiceContext)[NODE_DESKTOP_SERVICE_CONTEXT];
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../../shared/node-desktop-stream.js";
|
||||
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
||||
import type { NodeRegistry } from "../node-registry.js";
|
||||
import { DesktopCredentialsRequiredError } from "./host-source-errors.js";
|
||||
import type { NodeDesktopStreamBroker } from "./node-stream-broker.js";
|
||||
import { mintDesktopObserverToken } from "./observe-bridge.js";
|
||||
import type { DesktopSessionRegistry } from "./session-registry.js";
|
||||
|
||||
type NodeDesktopObserveResult = {
|
||||
transport: "rfb";
|
||||
wsPath: string;
|
||||
expiresAtMs: number;
|
||||
control: boolean;
|
||||
auth: "vnc-password" | "ard-account";
|
||||
preauthenticated: true;
|
||||
};
|
||||
|
||||
function invocationError(result: Awaited<ReturnType<NodeRegistry["invoke"]>>): Error {
|
||||
const message = result.error?.message?.trim();
|
||||
return new Error(message || "node desktop stream closed before attachment");
|
||||
}
|
||||
|
||||
type ActiveNodeDesktopStream = {
|
||||
controller: AbortController;
|
||||
ticket?: ReturnType<NodeDesktopStreamBroker["mint"]>;
|
||||
stream?: import("node:stream").Duplex;
|
||||
invocation?: ReturnType<NodeRegistry["invoke"]>;
|
||||
reservation?: ReturnType<DesktopSessionRegistry["reserveObserver"]>;
|
||||
reservationTransferred: boolean;
|
||||
unclaimedTimer?: ReturnType<typeof setTimeout>;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
type NodeDesktopSession = {
|
||||
connId: string;
|
||||
pairingGeneration: string;
|
||||
ownerEpoch: number;
|
||||
active: Set<ActiveNodeDesktopStream>;
|
||||
};
|
||||
|
||||
async function stopActiveStream(active: ActiveNodeDesktopStream): Promise<void> {
|
||||
if (active.stopped) {
|
||||
return;
|
||||
}
|
||||
retireActiveStream(active);
|
||||
await active.invocation?.catch(() => undefined);
|
||||
}
|
||||
|
||||
function retireActiveStream(active: ActiveNodeDesktopStream): void {
|
||||
if (active.stopped) {
|
||||
return;
|
||||
}
|
||||
active.stopped = true;
|
||||
clearTimeout(active.unclaimedTimer);
|
||||
active.ticket?.cancel();
|
||||
active.controller.abort();
|
||||
if (!active.reservationTransferred) {
|
||||
active.reservation?.release();
|
||||
}
|
||||
active.stream?.destroy();
|
||||
}
|
||||
|
||||
/** Combines node command policy, ticket redemption, and desktop session ownership. */
|
||||
export function createNodeDesktopService(params: {
|
||||
getConfig: () => OpenClawConfig;
|
||||
nodeRegistry: NodeRegistry;
|
||||
desktopRegistry: DesktopSessionRegistry;
|
||||
streamBroker: NodeDesktopStreamBroker;
|
||||
}) {
|
||||
const ownerEpochs = new Map<string, number>();
|
||||
const sessions = new Map<string, NodeDesktopSession>();
|
||||
|
||||
const ensureSession = async (request: {
|
||||
sourceKey: string;
|
||||
connId: string;
|
||||
pairingGeneration: string;
|
||||
}): Promise<NodeDesktopSession> => {
|
||||
const current = sessions.get(request.sourceKey);
|
||||
if (
|
||||
current?.connId === request.connId &&
|
||||
current.pairingGeneration === request.pairingGeneration
|
||||
) {
|
||||
await params.desktopRegistry.activate({
|
||||
sourceKey: request.sourceKey,
|
||||
ownerEpoch: current.ownerEpoch,
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
const ownerEpoch = (ownerEpochs.get(request.sourceKey) ?? 0) + 1;
|
||||
ownerEpochs.set(request.sourceKey, ownerEpoch);
|
||||
const session: NodeDesktopSession = {
|
||||
connId: request.connId,
|
||||
pairingGeneration: request.pairingGeneration,
|
||||
ownerEpoch,
|
||||
active: new Set(),
|
||||
};
|
||||
sessions.set(request.sourceKey, session);
|
||||
try {
|
||||
await params.desktopRegistry.activate({
|
||||
sourceKey: request.sourceKey,
|
||||
ownerEpoch,
|
||||
teardown: async () => {
|
||||
if (sessions.get(request.sourceKey) === session) {
|
||||
sessions.delete(request.sourceKey);
|
||||
}
|
||||
await Promise.all([...session.active].map(stopActiveStream));
|
||||
session.active.clear();
|
||||
},
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (sessions.get(request.sourceKey) === session) {
|
||||
sessions.delete(request.sourceKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async stopNode(nodeId: string): Promise<void> {
|
||||
const sourceKey = `node:${nodeId}`;
|
||||
const session = sessions.get(sourceKey);
|
||||
if (session) {
|
||||
await params.desktopRegistry.stop(sourceKey, session.ownerEpoch);
|
||||
}
|
||||
},
|
||||
async observe(request: {
|
||||
nodeId: string;
|
||||
control: boolean;
|
||||
credentials?: { username?: string; password?: string };
|
||||
}): Promise<NodeDesktopObserveResult> {
|
||||
const node = params.nodeRegistry.get(request.nodeId);
|
||||
if (!node?.pairingGeneration) {
|
||||
throw new Error("node desktop is unavailable; reconnect and approve the node capability");
|
||||
}
|
||||
const pairingGeneration = node.pairingGeneration;
|
||||
// NodeSession.commands is the generation-bound effective approval surface;
|
||||
// declaredCommands remains the broader capability advertised at connect.
|
||||
const allowlist = resolveNodeCommandAllowlist(params.getConfig(), node);
|
||||
const commandPolicy = isNodeCommandAllowed({
|
||||
command: NODE_DESKTOP_STREAM_COMMAND,
|
||||
declaredCommands: node.commands,
|
||||
allowlist,
|
||||
});
|
||||
if (!commandPolicy.ok) {
|
||||
throw new Error(
|
||||
"node desktop is not enabled; explicitly allow and approve desktop.stream for this node",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceKey = `node:${request.nodeId}`;
|
||||
const session = await ensureSession({
|
||||
sourceKey,
|
||||
connId: node.connId,
|
||||
pairingGeneration,
|
||||
});
|
||||
const active: ActiveNodeDesktopStream = {
|
||||
controller: new AbortController(),
|
||||
reservation: params.desktopRegistry.reserveObserver(sourceKey, session.ownerEpoch),
|
||||
reservationTransferred: false,
|
||||
stopped: false,
|
||||
};
|
||||
if (!active.reservation) {
|
||||
throw new Error("node desktop observer limit reached");
|
||||
}
|
||||
session.active.add(active);
|
||||
active.ticket = params.streamBroker.mint({
|
||||
nodeId: request.nodeId,
|
||||
connId: node.connId,
|
||||
pairingGeneration,
|
||||
});
|
||||
active.invocation = params.nodeRegistry.invoke({
|
||||
nodeId: request.nodeId,
|
||||
expectedConnId: node.connId,
|
||||
expectedPairingGeneration: pairingGeneration,
|
||||
command: NODE_DESKTOP_STREAM_COMMAND,
|
||||
params: { ticket: active.ticket.ticket, attachPath: active.ticket.attachPath },
|
||||
timeoutMs: 0,
|
||||
onProgress: () => {},
|
||||
signal: active.controller.signal,
|
||||
});
|
||||
const invocationFinished = active.invocation.then((result) => {
|
||||
throw invocationError(result);
|
||||
});
|
||||
void invocationFinished.catch(() => undefined);
|
||||
|
||||
let attached: Awaited<typeof active.ticket.attached>;
|
||||
try {
|
||||
attached = await Promise.race([active.ticket.attached, invocationFinished]);
|
||||
} catch (error) {
|
||||
await stopActiveStream(active);
|
||||
session.active.delete(active);
|
||||
throw error;
|
||||
}
|
||||
active.stream = attached.stream;
|
||||
|
||||
let password: string | undefined;
|
||||
try {
|
||||
if (attached.auth === "vnc-password") {
|
||||
password = attached.vncPassword ?? request.credentials?.password;
|
||||
if (!password) {
|
||||
throw new DesktopCredentialsRequiredError(
|
||||
"vnc-password",
|
||||
"VNC password is required to observe this node",
|
||||
);
|
||||
}
|
||||
registerSecretValueForRedaction(password);
|
||||
} else {
|
||||
const username = request.credentials?.username?.trim() ?? "";
|
||||
const ardPassword = request.credentials?.password ?? "";
|
||||
if (!username || !ardPassword) {
|
||||
throw new DesktopCredentialsRequiredError(
|
||||
"ard-account",
|
||||
"macOS account credentials are required to observe this node",
|
||||
);
|
||||
}
|
||||
registerSecretValueForRedaction(ardPassword);
|
||||
}
|
||||
} catch (error) {
|
||||
await stopActiveStream(active);
|
||||
session.active.delete(active);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const attachment = params.desktopRegistry.publishStream({
|
||||
sourceKey,
|
||||
ownerEpoch: session.ownerEpoch,
|
||||
stream: attached.stream,
|
||||
reservation: active.reservation,
|
||||
});
|
||||
if (!attachment) {
|
||||
await stopActiveStream(active);
|
||||
session.active.delete(active);
|
||||
throw new Error("node desktop session was superseded before publication");
|
||||
}
|
||||
active.reservationTransferred = true;
|
||||
const credentials = request.credentials;
|
||||
const preauth =
|
||||
attached.auth === "ard-account"
|
||||
? {
|
||||
auth: attached.auth,
|
||||
credentials: {
|
||||
username: credentials?.username?.trim() ?? "",
|
||||
password: credentials?.password ?? "",
|
||||
},
|
||||
}
|
||||
: {
|
||||
auth: attached.auth,
|
||||
credentials: { password: password ?? credentials?.password ?? "" },
|
||||
};
|
||||
const minted = mintDesktopObserverToken({
|
||||
sourceKey,
|
||||
ownerEpoch: session.ownerEpoch,
|
||||
control: request.control,
|
||||
attachment,
|
||||
preauth,
|
||||
});
|
||||
active.unclaimedTimer = setTimeout(
|
||||
() => {
|
||||
if (params.desktopRegistry.hasPendingStream(attachment)) {
|
||||
void stopActiveStream(active).then(() => session.active.delete(active));
|
||||
}
|
||||
},
|
||||
Math.max(0, minted.expiresAtMs - Date.now()),
|
||||
);
|
||||
active.unclaimedTimer.unref?.();
|
||||
void active.invocation
|
||||
.finally(() => {
|
||||
retireActiveStream(active);
|
||||
session.active.delete(active);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return {
|
||||
transport: "rfb",
|
||||
wsPath: `/desktop/observe?token=${minted.token}`,
|
||||
expiresAtMs: minted.expiresAtMs,
|
||||
control: request.control,
|
||||
auth: attached.auth,
|
||||
preauthenticated: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type NodeDesktopService = ReturnType<typeof createNodeDesktopService>;
|
||||
@@ -0,0 +1,273 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { createNodeDesktopStreamBroker } from "./node-stream-broker.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
||||
});
|
||||
|
||||
async function startBrokerServer(params: {
|
||||
broker: ReturnType<typeof createNodeDesktopStreamBroker>;
|
||||
session: { connId: string; pairingGeneration: string };
|
||||
pairingCurrent?: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const registry = {
|
||||
getForPairingGeneration: (_nodeId: string, pairingGeneration: string) =>
|
||||
pairingGeneration === params.session.pairingGeneration
|
||||
? { connId: params.session.connId }
|
||||
: undefined,
|
||||
isConnectionCurrentPairingState: async (connId: string) =>
|
||||
connId === params.session.connId && (await (params.pairingCurrent?.() ?? true)),
|
||||
};
|
||||
const server = http.createServer();
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
void params.broker.handleUpgrade(req, socket, head, registry as never);
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected broker test address");
|
||||
}
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
);
|
||||
return `ws://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function connectAndSend(url: string, metadata: object): Promise<WebSocket> {
|
||||
const ws = new WebSocket(url);
|
||||
cleanups.push(async () => ws.terminate());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
ws.send(Buffer.from(JSON.stringify(metadata)), { binary: true });
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function expectUnauthorized(url: string): Promise<void> {
|
||||
const ws = new WebSocket(url);
|
||||
cleanups.push(async () => ws.terminate());
|
||||
await expect(
|
||||
new Promise<number>((resolve, reject) => {
|
||||
ws.once("unexpected-response", (_request, response) => resolve(response.statusCode ?? 0));
|
||||
ws.once("open", () => reject(new Error("unexpected node desktop attachment")));
|
||||
ws.once("error", () => undefined);
|
||||
}),
|
||||
).resolves.toBe(401);
|
||||
}
|
||||
|
||||
describe("node desktop stream tickets", () => {
|
||||
it("is single-use and resolves one ticket-bound binary stream", async () => {
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({ broker, session });
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
|
||||
await connectAndSend(`${baseUrl}${minted.attachPath}`, { auth: "vnc-password" });
|
||||
const attached = await minted.attached;
|
||||
expect(attached.auth).toBe("vnc-password");
|
||||
attached.stream.destroy();
|
||||
|
||||
await expectUnauthorized(`${baseUrl}${minted.attachPath}`);
|
||||
});
|
||||
|
||||
it("buffers early RFB bytes while the pairing binding is rechecked", async () => {
|
||||
let pairingChecks = 0;
|
||||
let releaseRecheck!: () => void;
|
||||
const recheck = new Promise<void>((resolve) => {
|
||||
releaseRecheck = resolve;
|
||||
});
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({
|
||||
broker,
|
||||
session,
|
||||
pairingCurrent: async () => {
|
||||
pairingChecks += 1;
|
||||
if (pairingChecks > 1) {
|
||||
await recheck;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
const ws = await connectAndSend(`${baseUrl}${minted.attachPath}`, {
|
||||
auth: "vnc-password",
|
||||
});
|
||||
const earlyBanner = Buffer.from("RFB 003.008\n", "ascii");
|
||||
ws.send(earlyBanner, { binary: true });
|
||||
await vi.waitFor(() => expect(pairingChecks).toBe(2));
|
||||
releaseRecheck();
|
||||
|
||||
const attached = await minted.attached;
|
||||
await expect(
|
||||
new Promise<Buffer>((resolve) => {
|
||||
attached.stream.once("data", resolve);
|
||||
}),
|
||||
).resolves.toEqual(earlyBanner);
|
||||
attached.stream.destroy();
|
||||
});
|
||||
|
||||
it("rejects a stream error during the asynchronous pairing handoff", async () => {
|
||||
let pairingChecks = 0;
|
||||
let releaseRecheck!: () => void;
|
||||
const recheck = new Promise<void>((resolve) => {
|
||||
releaseRecheck = resolve;
|
||||
});
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({
|
||||
broker,
|
||||
session,
|
||||
pairingCurrent: async () => {
|
||||
pairingChecks += 1;
|
||||
if (pairingChecks > 1) {
|
||||
await recheck;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
const ws = await connectAndSend(`${baseUrl}${minted.attachPath}`, {
|
||||
auth: "vnc-password",
|
||||
});
|
||||
await vi.waitFor(() => expect(pairingChecks).toBe(2));
|
||||
ws.send(Buffer.alloc(65 * 1024), { binary: true });
|
||||
|
||||
await expect(minted.attached).rejects.toThrow();
|
||||
releaseRecheck();
|
||||
});
|
||||
|
||||
it("rejects invalid metadata without exposing later WebSocket errors", async () => {
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({ broker, session });
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
const ws = await connectAndSend(`${baseUrl}${minted.attachPath}`, { auth: "none" });
|
||||
ws.send(Buffer.alloc(65 * 1024), { binary: true });
|
||||
|
||||
await expect(minted.attached).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an expired ticket before upgrading", async () => {
|
||||
let now = 1_000;
|
||||
const broker = createNodeDesktopStreamBroker({ ttlMs: 60_000, now: () => now });
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({ broker, session });
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
now = minted.expiresAtMs;
|
||||
|
||||
await expectUnauthorized(`${baseUrl}${minted.attachPath}`);
|
||||
await expect(minted.attached).rejects.toThrow("expired");
|
||||
});
|
||||
|
||||
it("rejects a ticket after connection or pairing generation replacement", async () => {
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({ broker, session });
|
||||
const oldConnection = broker.mint({ nodeId: "node-1", ...session });
|
||||
session.connId = "conn-2";
|
||||
await expectUnauthorized(`${baseUrl}${oldConnection.attachPath}`);
|
||||
await expect(oldConnection.attached).rejects.toThrow("stale");
|
||||
|
||||
const oldGeneration = broker.mint({ nodeId: "node-1", ...session });
|
||||
session.pairingGeneration = "generation-2";
|
||||
await expectUnauthorized(`${baseUrl}${oldGeneration.attachPath}`);
|
||||
await expect(oldGeneration.attached).rejects.toThrow("stale");
|
||||
});
|
||||
|
||||
it("rechecks the pairing generation after a delayed metadata frame", async () => {
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({ broker, session });
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
const ws = new WebSocket(`${baseUrl}${minted.attachPath}`);
|
||||
cleanups.push(async () => ws.terminate());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
session.pairingGeneration = "generation-2";
|
||||
ws.send(Buffer.from(JSON.stringify({ auth: "vnc-password" })), { binary: true });
|
||||
|
||||
await expect(minted.attached).rejects.toThrow("stale");
|
||||
});
|
||||
|
||||
it("keeps a redeemed ticket cancellable while metadata is pending", async () => {
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({ broker, session });
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
const ws = new WebSocket(`${baseUrl}${minted.attachPath}`);
|
||||
cleanups.push(async () => ws.terminate());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
ws.once("close", () => resolve());
|
||||
});
|
||||
|
||||
minted.cancel();
|
||||
|
||||
await expect(minted.attached).rejects.toThrow("cancelled");
|
||||
await expect(closed).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when the raw upgrade socket closes during pairing authorization", async () => {
|
||||
let pairingChecks = 0;
|
||||
let releaseCheck!: () => void;
|
||||
const check = new Promise<void>((resolve) => {
|
||||
releaseCheck = resolve;
|
||||
});
|
||||
const broker = createNodeDesktopStreamBroker();
|
||||
const session = { connId: "conn-1", pairingGeneration: "generation-1" };
|
||||
const baseUrl = await startBrokerServer({
|
||||
broker,
|
||||
session,
|
||||
pairingCurrent: async () => {
|
||||
pairingChecks += 1;
|
||||
await check;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const minted = broker.mint({ nodeId: "node-1", ...session });
|
||||
const url = new URL(baseUrl);
|
||||
const socket = net.createConnection(Number(url.port), url.hostname);
|
||||
cleanups.push(async () => {
|
||||
socket.destroy();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
socket.write(
|
||||
[
|
||||
`GET ${minted.attachPath} HTTP/1.1`,
|
||||
`Host: ${url.host}`,
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: websocket",
|
||||
"Sec-WebSocket-Version: 13",
|
||||
"Sec-WebSocket-Key: dGVzdC1ub25jZS0xMjM0NQ==",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
await vi.waitFor(() => expect(pairingChecks).toBe(1));
|
||||
|
||||
socket.destroy();
|
||||
|
||||
await expect(minted.attached).rejects.toThrow("authorization");
|
||||
releaseCheck();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { createWebSocketStream, WebSocket, WebSocketServer, type RawData } from "ws";
|
||||
import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import { NODE_DESKTOP_ATTACH_PATH } from "../../shared/node-desktop-stream.js";
|
||||
import type { NodeRegistry } from "../node-registry.js";
|
||||
|
||||
const DEFAULT_TICKET_TTL_MS = 60_000;
|
||||
const TICKET_PATTERN = /^[a-f0-9]{48}$/u;
|
||||
const MAX_ATTACH_FRAME_BYTES = 64 * 1024;
|
||||
|
||||
type NodeDesktopStreamMetadata = {
|
||||
auth: "vnc-password" | "ard-account";
|
||||
vncPassword?: string;
|
||||
};
|
||||
|
||||
type AttachedNodeDesktopStream = NodeDesktopStreamMetadata & { stream: Duplex };
|
||||
|
||||
type NodeDesktopStreamBinding = {
|
||||
nodeId: string;
|
||||
connId: string;
|
||||
pairingGeneration: string;
|
||||
};
|
||||
|
||||
type TicketEntry = {
|
||||
binding: NodeDesktopStreamBinding;
|
||||
expiresAtMs: number;
|
||||
resolve: (attached: AttachedNodeDesktopStream) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
redeemed: boolean;
|
||||
settled: boolean;
|
||||
socket?: Duplex;
|
||||
ws?: WebSocket;
|
||||
};
|
||||
|
||||
type TicketNodeRegistry = Pick<
|
||||
NodeRegistry,
|
||||
"getForPairingGeneration" | "isConnectionCurrentPairingState"
|
||||
>;
|
||||
|
||||
function rawDataBuffer(data: RawData): Buffer {
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(data);
|
||||
}
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
function parseStreamMetadata(data: RawData, isBinary: boolean): NodeDesktopStreamMetadata {
|
||||
const buffer = rawDataBuffer(data);
|
||||
if (!isBinary || buffer.length === 0 || buffer.length > MAX_ATTACH_FRAME_BYTES) {
|
||||
throw new Error("invalid node desktop attach metadata");
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(buffer.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("invalid node desktop attach metadata");
|
||||
}
|
||||
if (!isRecord(value) || (value.auth !== "vnc-password" && value.auth !== "ard-account")) {
|
||||
throw new Error("invalid node desktop attach metadata");
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.some((key) => key !== "auth" && key !== "vncPassword")) {
|
||||
throw new Error("invalid node desktop attach metadata");
|
||||
}
|
||||
if (value.vncPassword !== undefined && typeof value.vncPassword !== "string") {
|
||||
throw new Error("invalid node desktop attach metadata");
|
||||
}
|
||||
if (value.auth === "ard-account" && value.vncPassword !== undefined) {
|
||||
throw new Error("invalid node desktop attach metadata");
|
||||
}
|
||||
const vncPassword = typeof value.vncPassword === "string" ? value.vncPassword : undefined;
|
||||
if (vncPassword) {
|
||||
registerSecretValueForRedaction(vncPassword);
|
||||
}
|
||||
return { auth: value.auth, ...(vncPassword ? { vncPassword } : {}) };
|
||||
}
|
||||
|
||||
function writeUnauthorized(socket: Duplex): void {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
function readAttachedStream(
|
||||
ws: WebSocket,
|
||||
onStreamError: (error: Error) => void,
|
||||
): Promise<{ metadata: NodeDesktopStreamMetadata; stream: Duplex }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
ws.off("message", onMessage);
|
||||
ws.off("close", onClose);
|
||||
};
|
||||
const onMessage = (data: RawData, isBinary: boolean) => {
|
||||
cleanup();
|
||||
try {
|
||||
const metadata = parseStreamMetadata(data, isBinary);
|
||||
// Install the stream listener in the same message turn. The pairing
|
||||
// recheck may yield, but early RFB banner bytes must already be buffered.
|
||||
const stream = createWebSocketStream(ws, { allowHalfOpen: false });
|
||||
// Retain a safety listener through the asynchronous registry handoff;
|
||||
// the observer bridge adds its own lifecycle handler after claiming it.
|
||||
stream.on("error", onStreamError);
|
||||
resolve({
|
||||
metadata,
|
||||
stream,
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
reject(new Error("node desktop stream closed before attach"));
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
onStreamError(error);
|
||||
reject(error);
|
||||
};
|
||||
ws.once("message", onMessage);
|
||||
ws.once("close", onClose);
|
||||
// Keep this listener for the WebSocket lifetime. Invalid metadata never
|
||||
// creates a Duplex, so it remains the terminal protocol-error guard.
|
||||
ws.on("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
/** Owns one-time node stream tickets and turns redeemed WebSockets into RFB duplexes. */
|
||||
export function createNodeDesktopStreamBroker(deps: { ttlMs?: number; now?: () => number } = {}) {
|
||||
const ttlMs = deps.ttlMs ?? DEFAULT_TICKET_TTL_MS;
|
||||
const now = deps.now ?? Date.now;
|
||||
const tickets = new Map<string, TicketEntry>();
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_ATTACH_FRAME_BYTES });
|
||||
|
||||
const remove = (ticket: string): TicketEntry | undefined => {
|
||||
const entry = tickets.get(ticket);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
tickets.delete(ticket);
|
||||
clearTimeout(entry.timer);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const rejectTicket = (ticket: string, error: Error): void => {
|
||||
const entry = remove(ticket);
|
||||
if (!entry || entry.settled) {
|
||||
return;
|
||||
}
|
||||
entry.settled = true;
|
||||
entry.reject(error);
|
||||
entry.ws?.close(1008, "node desktop attach rejected");
|
||||
entry.socket?.destroy();
|
||||
};
|
||||
|
||||
const resolveTicket = (ticket: string, attached: AttachedNodeDesktopStream): void => {
|
||||
const entry = remove(ticket);
|
||||
if (!entry || entry.settled) {
|
||||
attached.stream.destroy();
|
||||
return;
|
||||
}
|
||||
entry.settled = true;
|
||||
entry.resolve(attached);
|
||||
};
|
||||
|
||||
function mint(binding: NodeDesktopStreamBinding) {
|
||||
const ticket = crypto.randomBytes(24).toString("hex");
|
||||
const expiresAtMs = now() + ttlMs;
|
||||
let resolve!: (attached: AttachedNodeDesktopStream) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const attached = new Promise<AttachedNodeDesktopStream>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
void attached.catch(() => undefined);
|
||||
const timer = setTimeout(() => {
|
||||
rejectTicket(ticket, new Error("node desktop stream ticket expired"));
|
||||
}, ttlMs);
|
||||
timer.unref?.();
|
||||
tickets.set(ticket, {
|
||||
binding,
|
||||
expiresAtMs,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
redeemed: false,
|
||||
settled: false,
|
||||
});
|
||||
return {
|
||||
ticket,
|
||||
attachPath: `${NODE_DESKTOP_ATTACH_PATH}?ticket=${ticket}`,
|
||||
expiresAtMs,
|
||||
attached,
|
||||
cancel() {
|
||||
rejectTicket(ticket, new Error("node desktop stream ticket cancelled"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bindingIsCurrent = async (
|
||||
registry: TicketNodeRegistry,
|
||||
binding: NodeDesktopStreamBinding,
|
||||
): Promise<boolean> => {
|
||||
const current = registry.getForPairingGeneration(binding.nodeId, binding.pairingGeneration);
|
||||
if (!current || current.connId !== binding.connId) {
|
||||
return false;
|
||||
}
|
||||
if (!(await registry.isConnectionCurrentPairingState(binding.connId))) {
|
||||
return false;
|
||||
}
|
||||
const rechecked = registry.getForPairingGeneration(binding.nodeId, binding.pairingGeneration);
|
||||
return rechecked?.connId === binding.connId;
|
||||
};
|
||||
|
||||
async function handleUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
registry: TicketNodeRegistry,
|
||||
): Promise<boolean> {
|
||||
const resource = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (resource.pathname !== NODE_DESKTOP_ATTACH_PATH) {
|
||||
return false;
|
||||
}
|
||||
const ticket = (resource.searchParams.get("ticket") ?? "").trim();
|
||||
if (!TICKET_PATTERN.test(ticket)) {
|
||||
writeUnauthorized(socket);
|
||||
return true;
|
||||
}
|
||||
const entry = tickets.get(ticket);
|
||||
if (!entry || entry.redeemed || entry.expiresAtMs <= now()) {
|
||||
writeUnauthorized(socket);
|
||||
if (entry && !entry.redeemed) {
|
||||
rejectTicket(ticket, new Error("node desktop stream ticket expired"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
entry.redeemed = true;
|
||||
entry.socket = socket;
|
||||
const onSocketError = (error: Error) => rejectTicket(ticket, error);
|
||||
const onSocketClose = () =>
|
||||
rejectTicket(ticket, new Error("node desktop attach closed during authorization"));
|
||||
socket.once("error", onSocketError);
|
||||
socket.once("end", onSocketClose);
|
||||
socket.once("close", onSocketClose);
|
||||
let current: boolean;
|
||||
try {
|
||||
current = await bindingIsCurrent(registry, entry.binding);
|
||||
} catch {
|
||||
current = false;
|
||||
}
|
||||
if (entry.settled) {
|
||||
return true;
|
||||
}
|
||||
if (!current) {
|
||||
socket.off("error", onSocketError);
|
||||
socket.off("end", onSocketClose);
|
||||
socket.off("close", onSocketClose);
|
||||
writeUnauthorized(socket);
|
||||
rejectTicket(ticket, new Error("node desktop stream ticket binding is stale"));
|
||||
return true;
|
||||
}
|
||||
socket.off("error", onSocketError);
|
||||
socket.off("end", onSocketClose);
|
||||
socket.off("close", onSocketClose);
|
||||
try {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
entry.socket = undefined;
|
||||
entry.ws = ws;
|
||||
const attached = readAttachedStream(ws, (error) => rejectTicket(ticket, error));
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await attached;
|
||||
if (!(await bindingIsCurrent(registry, entry.binding))) {
|
||||
throw new Error("node desktop stream ticket binding is stale");
|
||||
}
|
||||
resolveTicket(ticket, {
|
||||
...resolved.metadata,
|
||||
stream: resolved.stream,
|
||||
});
|
||||
} catch (error) {
|
||||
rejectTicket(ticket, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
})();
|
||||
});
|
||||
} catch (error) {
|
||||
rejectTicket(ticket, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { mint, handleUpgrade };
|
||||
}
|
||||
|
||||
export type NodeDesktopStreamBroker = ReturnType<typeof createNodeDesktopStreamBroker>;
|
||||
@@ -62,6 +62,7 @@ async function createProxyHarness(
|
||||
httpServer.on("upgrade", (req, socket, head) => {
|
||||
handleDesktopObserveUpgrade(req, socket, head, {
|
||||
registry: {
|
||||
claimStream: () => undefined,
|
||||
attachObserver: (_environmentId, observer) => {
|
||||
closeObserver.mockImplementation((code: number, reason: string) => {
|
||||
observer.close(code, reason);
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { WebSocket, WebSocketServer, type RawData } from "ws";
|
||||
import { connectRfbAttachment, type RfbAttachment } from "./attachment.js";
|
||||
import { connectRfbAttachment, type DesktopRfbAttachment } from "./attachment.js";
|
||||
import {
|
||||
preauthenticateRfb,
|
||||
RfbPreauthBuffer,
|
||||
@@ -24,7 +24,7 @@ type DesktopObserverTokenEntry = {
|
||||
sourceKey: string;
|
||||
ownerEpoch: number;
|
||||
control: boolean;
|
||||
attachment: RfbAttachment;
|
||||
attachment: DesktopRfbAttachment;
|
||||
preauth?: RfbPreauthDescriptor;
|
||||
expiresAt: number;
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export function mintDesktopObserverToken(params: {
|
||||
sourceKey: string;
|
||||
ownerEpoch: number;
|
||||
control: boolean;
|
||||
attachment: RfbAttachment;
|
||||
attachment: DesktopRfbAttachment;
|
||||
preauth?: RfbPreauthDescriptor;
|
||||
nowMs?: number;
|
||||
}): { token: string; expiresAtMs: number } {
|
||||
@@ -178,7 +178,7 @@ export function handleDesktopObserveUpgrade(
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
deps: {
|
||||
registry: Pick<DesktopSessionRegistry, "attachObserver">;
|
||||
registry: Pick<DesktopSessionRegistry, "attachObserver" | "claimStream">;
|
||||
getBufferedAmount?: (ws: WebSocket) => number;
|
||||
},
|
||||
): boolean {
|
||||
@@ -193,6 +193,12 @@ export function handleDesktopObserveUpgrade(
|
||||
return true;
|
||||
}
|
||||
desktopObserverWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const claimedStream =
|
||||
entry.attachment.kind === "stream" ? deps.registry.claimStream(entry.attachment) : undefined;
|
||||
if (entry.attachment.kind === "stream" && !claimedStream) {
|
||||
ws.close(1013, "desktop stream unavailable");
|
||||
return;
|
||||
}
|
||||
// View-only is enforced here at the RFB message boundary; the UI setting is only UX.
|
||||
const observer = deps.registry.attachObserver(entry.sourceKey, {
|
||||
control: entry.control,
|
||||
@@ -200,10 +206,17 @@ export function handleDesktopObserveUpgrade(
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
});
|
||||
if (!observer) {
|
||||
claimedStream?.destroy();
|
||||
ws.close(1013, "desktop observer limit");
|
||||
return;
|
||||
}
|
||||
const desktopSocket = connectRfbAttachment(entry.attachment);
|
||||
const desktopSocket =
|
||||
entry.attachment.kind === "stream" ? claimedStream : connectRfbAttachment(entry.attachment);
|
||||
if (!desktopSocket) {
|
||||
observer.release();
|
||||
ws.close(1013, "desktop stream unavailable");
|
||||
return;
|
||||
}
|
||||
let closed = false;
|
||||
let negotiating = Boolean(entry.preauth);
|
||||
let resumeTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RfbAttachment } from "./attachment.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ConnectedRfbStream, DesktopRfbAttachment } from "./attachment.js";
|
||||
|
||||
const DEFAULT_LINGER_MS = 60_000;
|
||||
const MAX_OBSERVERS = 8;
|
||||
@@ -25,7 +26,7 @@ type DesktopSessionObserver = {
|
||||
};
|
||||
|
||||
type DesktopSessionAcquireResult = {
|
||||
attachment: RfbAttachment;
|
||||
attachment: DesktopRfbAttachment;
|
||||
auth?: "vnc-password" | "ard-account";
|
||||
vncPassword?: string;
|
||||
};
|
||||
@@ -37,22 +38,27 @@ type DesktopSessionAcquireRequest = {
|
||||
teardown?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type DesktopSessionActivateRequest = Omit<DesktopSessionAcquireRequest, "start">;
|
||||
type DesktopSessionStartResult = DesktopSessionAcquireResult | undefined;
|
||||
|
||||
type ObserverEntry = DesktopSessionObserver & { released: boolean };
|
||||
type DesktopSessionEntry = {
|
||||
sourceKey: string;
|
||||
ownerEpoch: number;
|
||||
initialization?: Promise<void>;
|
||||
stopPromise?: Promise<void>;
|
||||
ready: Promise<DesktopSessionAcquireResult>;
|
||||
resolveReady: (result: DesktopSessionAcquireResult) => void;
|
||||
ready: Promise<DesktopSessionStartResult>;
|
||||
resolveReady: (result: DesktopSessionStartResult) => void;
|
||||
rejectReady: (error: Error) => void;
|
||||
readySettled: boolean;
|
||||
observers: Set<ObserverEntry>;
|
||||
observerReservations: Set<symbol>;
|
||||
controller?: ObserverEntry;
|
||||
lingerTimer?: ReturnType<typeof setTimeout>;
|
||||
stopped: boolean;
|
||||
start: DesktopSessionAcquireRequest["start"];
|
||||
start: (isCurrent: () => boolean) => Promise<DesktopSessionStartResult>;
|
||||
teardown?: DesktopSessionAcquireRequest["teardown"];
|
||||
pendingStreams: Map<string, { stream: ConnectedRfbStream; reservation: { release(): void } }>;
|
||||
};
|
||||
|
||||
/** Owns per-source desktop sessions and their connected observer lifetimes. */
|
||||
@@ -105,6 +111,12 @@ export function createDesktopSessionRegistry(
|
||||
}
|
||||
entry.observers.clear();
|
||||
entry.controller = undefined;
|
||||
for (const pending of entry.pendingStreams.values()) {
|
||||
pending.reservation.release();
|
||||
pending.stream.destroy();
|
||||
}
|
||||
entry.pendingStreams.clear();
|
||||
entry.observerReservations.clear();
|
||||
if (!entry.readySettled) {
|
||||
entry.readySettled = true;
|
||||
entry.rejectReady(new DesktopSessionStoppedError());
|
||||
@@ -118,9 +130,17 @@ export function createDesktopSessionRegistry(
|
||||
return entry.stopPromise;
|
||||
};
|
||||
|
||||
async function acquire(
|
||||
request: DesktopSessionAcquireRequest,
|
||||
): Promise<DesktopSessionAcquireResult> {
|
||||
const scheduleLinger = (entry: DesktopSessionEntry): void => {
|
||||
clearTimeout(entry.lingerTimer);
|
||||
entry.lingerTimer = setTimeout(() => void stopEntry(entry), lingerMs);
|
||||
entry.lingerTimer.unref?.();
|
||||
};
|
||||
|
||||
async function startSession(
|
||||
request:
|
||||
| DesktopSessionAcquireRequest
|
||||
| (DesktopSessionActivateRequest & { start: () => Promise<undefined> }),
|
||||
): Promise<DesktopSessionStartResult> {
|
||||
claimOwnerEpoch(request.sourceKey, request.ownerEpoch);
|
||||
const current = entries.get(request.sourceKey);
|
||||
if (current) {
|
||||
@@ -132,9 +152,9 @@ export function createDesktopSessionRegistry(
|
||||
}
|
||||
}
|
||||
|
||||
let resolveReady!: (result: DesktopSessionAcquireResult) => void;
|
||||
let resolveReady!: (result: DesktopSessionStartResult) => void;
|
||||
let rejectReady!: (error: Error) => void;
|
||||
const ready = new Promise<DesktopSessionAcquireResult>((resolve, reject) => {
|
||||
const ready = new Promise<DesktopSessionStartResult>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
@@ -147,6 +167,8 @@ export function createDesktopSessionRegistry(
|
||||
rejectReady,
|
||||
readySettled: false,
|
||||
observers: new Set(),
|
||||
observerReservations: new Set(),
|
||||
pendingStreams: new Map(),
|
||||
stopped: false,
|
||||
start: request.start,
|
||||
...(request.teardown ? { teardown: request.teardown } : {}),
|
||||
@@ -176,9 +198,36 @@ export function createDesktopSessionRegistry(
|
||||
return await ready;
|
||||
}
|
||||
|
||||
async function acquire(
|
||||
request: DesktopSessionAcquireRequest,
|
||||
): Promise<DesktopSessionAcquireResult> {
|
||||
const result = await startSession(request);
|
||||
if (!result) {
|
||||
throw new Error("Desktop session attachment is unavailable");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function activate(request: DesktopSessionActivateRequest): Promise<void> {
|
||||
await startSession({ ...request, start: async () => undefined });
|
||||
const entry = entries.get(request.sourceKey);
|
||||
if (
|
||||
entry?.ownerEpoch === request.ownerEpoch &&
|
||||
entry.observers.size === 0 &&
|
||||
entry.observerReservations.size === 0
|
||||
) {
|
||||
scheduleLinger(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function attachObserver(sourceKey: string, observer: DesktopSessionObserver) {
|
||||
const entry = entries.get(sourceKey);
|
||||
if (!entry || !entry.readySettled || entry.stopped || entry.observers.size >= MAX_OBSERVERS) {
|
||||
if (
|
||||
!entry ||
|
||||
!entry.readySettled ||
|
||||
entry.stopped ||
|
||||
entry.observers.size + entry.observerReservations.size >= MAX_OBSERVERS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// A token minted against a replaced entry must not reach this one; otherwise a stale
|
||||
@@ -210,14 +259,114 @@ export function createDesktopSessionRegistry(
|
||||
if (entry.controller === attached) {
|
||||
entry.controller = undefined;
|
||||
}
|
||||
if (entry.observers.size === 0 && isCurrent(entry)) {
|
||||
entry.lingerTimer = setTimeout(() => void stopEntry(entry), lingerMs);
|
||||
entry.lingerTimer.unref?.();
|
||||
if (
|
||||
entry.observers.size === 0 &&
|
||||
entry.observerReservations.size === 0 &&
|
||||
isCurrent(entry)
|
||||
) {
|
||||
scheduleLinger(entry);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reserveObserver(sourceKey: string, ownerEpoch: number) {
|
||||
const entry = entries.get(sourceKey);
|
||||
if (
|
||||
!entry ||
|
||||
entry.stopped ||
|
||||
entry.ownerEpoch !== ownerEpoch ||
|
||||
entry.observers.size + entry.observerReservations.size >= MAX_OBSERVERS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const reservationId = Symbol("desktop-observer");
|
||||
entry.observerReservations.add(reservationId);
|
||||
clearTimeout(entry.lingerTimer);
|
||||
entry.lingerTimer = undefined;
|
||||
let released = false;
|
||||
return {
|
||||
sourceKey,
|
||||
ownerEpoch,
|
||||
release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
entry.observerReservations.delete(reservationId);
|
||||
if (
|
||||
entry.observers.size === 0 &&
|
||||
entry.observerReservations.size === 0 &&
|
||||
isCurrent(entry)
|
||||
) {
|
||||
scheduleLinger(entry);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function publishStream(params: {
|
||||
sourceKey: string;
|
||||
ownerEpoch: number;
|
||||
stream: ConnectedRfbStream;
|
||||
reservation: NonNullable<ReturnType<typeof reserveObserver>>;
|
||||
}) {
|
||||
const entry = entries.get(params.sourceKey);
|
||||
if (
|
||||
!entry ||
|
||||
entry.stopped ||
|
||||
entry.ownerEpoch !== params.ownerEpoch ||
|
||||
params.reservation.sourceKey !== params.sourceKey ||
|
||||
params.reservation.ownerEpoch !== params.ownerEpoch
|
||||
) {
|
||||
params.reservation.release();
|
||||
params.stream.destroy();
|
||||
return undefined;
|
||||
}
|
||||
if (params.stream.destroyed || params.stream.readableEnded || params.stream.writableEnded) {
|
||||
params.reservation.release();
|
||||
params.stream.destroy();
|
||||
return undefined;
|
||||
}
|
||||
const streamId = randomUUID();
|
||||
const pending = { stream: params.stream, reservation: params.reservation };
|
||||
entry.pendingStreams.set(streamId, pending);
|
||||
params.stream.once("close", () => {
|
||||
if (entry.pendingStreams.get(streamId) === pending) {
|
||||
entry.pendingStreams.delete(streamId);
|
||||
params.reservation.release();
|
||||
}
|
||||
});
|
||||
return { kind: "stream", streamId } as const;
|
||||
}
|
||||
|
||||
function claimStream(attachment: { kind: "stream"; streamId: string }) {
|
||||
for (const entry of entries.values()) {
|
||||
const pending = entry.pendingStreams.get(attachment.streamId);
|
||||
if (!pending) {
|
||||
continue;
|
||||
}
|
||||
entry.pendingStreams.delete(attachment.streamId);
|
||||
pending.reservation.release();
|
||||
const stream = pending.stream;
|
||||
if (stream.destroyed || stream.readableEnded || stream.writableEnded) {
|
||||
stream.destroy();
|
||||
return undefined;
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasPendingStream(attachment: { kind: "stream"; streamId: string }): boolean {
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.pendingStreams.has(attachment.streamId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function stop(sourceKey: string, ownerEpoch?: number): Promise<void> {
|
||||
const entry = entries.get(sourceKey);
|
||||
if (entry && (ownerEpoch === undefined || ownerEpoch === entry.ownerEpoch)) {
|
||||
@@ -242,7 +391,12 @@ export function createDesktopSessionRegistry(
|
||||
|
||||
return {
|
||||
acquire,
|
||||
activate,
|
||||
attachObserver,
|
||||
publishStream,
|
||||
claimStream,
|
||||
hasPendingStream,
|
||||
reserveObserver,
|
||||
claimOwnerEpoch,
|
||||
isOwnerEpochCurrent: (sourceKey: string, ownerEpoch: number) =>
|
||||
claimedOwnerEpochs.get(sourceKey) === ownerEpoch,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../shared/node-desktop-stream.js";
|
||||
import {
|
||||
isForegroundRestrictedPluginNodeCommand,
|
||||
isNodeCommandAllowed,
|
||||
@@ -45,6 +46,59 @@ describe("gateway/node-command-policy", () => {
|
||||
return registry;
|
||||
}
|
||||
|
||||
it("keeps desktop streaming dangerous, advertised, explicitly allowed, and deny-wins", () => {
|
||||
const node = {
|
||||
platform: "linux",
|
||||
deviceFamily: "Linux",
|
||||
commands: [NODE_DESKTOP_STREAM_COMMAND],
|
||||
approvedCommands: [NODE_DESKTOP_STREAM_COMMAND],
|
||||
};
|
||||
expect(
|
||||
resolveNodeCommandAllowlist({} as OpenClawConfig, node).has(NODE_DESKTOP_STREAM_COMMAND),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveNodePairingCommandAllowlist({} as OpenClawConfig, {
|
||||
platform: node.platform,
|
||||
deviceFamily: node.deviceFamily,
|
||||
commands: node.commands,
|
||||
}).has(NODE_DESKTOP_STREAM_COMMAND),
|
||||
).toBe(false);
|
||||
|
||||
const allowedConfig = {
|
||||
gateway: { nodes: { commands: { allow: [NODE_DESKTOP_STREAM_COMMAND] } } },
|
||||
} as OpenClawConfig;
|
||||
const allowed = resolveNodeCommandAllowlist(allowedConfig, node);
|
||||
expect(
|
||||
isNodeCommandAllowed({
|
||||
command: NODE_DESKTOP_STREAM_COMMAND,
|
||||
declaredCommands: node.commands,
|
||||
allowlist: allowed,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(
|
||||
isNodeCommandAllowed({
|
||||
command: NODE_DESKTOP_STREAM_COMMAND,
|
||||
declaredCommands: [],
|
||||
allowlist: allowed,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "node did not declare commands" });
|
||||
|
||||
const denied = resolveNodeCommandAllowlist(
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
commands: {
|
||||
allow: [NODE_DESKTOP_STREAM_COMMAND],
|
||||
deny: [NODE_DESKTOP_STREAM_COMMAND],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
node,
|
||||
);
|
||||
expect(denied.has(NODE_DESKTOP_STREAM_COMMAND)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes declared node commands against the allowlist", () => {
|
||||
const allowlist = new Set(["canvas.snapshot", "system.run"]);
|
||||
expect(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { getActivePluginGatewayNodePolicyRegistry } from "../plugins/runtime.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../shared/node-desktop-stream.js";
|
||||
import { normalizeDeviceMetadataForPolicy } from "./device-metadata-normalization.js";
|
||||
import { MOBILE_NODE_COMMANDS } from "./node-command-policy-mobile.js";
|
||||
import type { NodeSession } from "./node-registry.js";
|
||||
@@ -25,7 +26,7 @@ const MAC_CAMERA_COMMANDS = ["camera.ptz.status"];
|
||||
const CAMERA_DANGEROUS_COMMANDS = ["camera.snap", "camera.clip", "camera.ptz.control"];
|
||||
|
||||
const SCREEN_COMMANDS = ["screen.snapshot"];
|
||||
const SCREEN_DANGEROUS_COMMANDS = ["screen.record"];
|
||||
const SCREEN_DANGEROUS_COMMANDS = ["screen.record", NODE_DESKTOP_STREAM_COMMAND];
|
||||
|
||||
// Desktop computer use is advertised only while the node-local control is
|
||||
// enabled. Pairing approval of that advertised surface is the durable grant.
|
||||
@@ -92,6 +93,7 @@ const DESKTOP_HOST_COMMANDS = new Set<string>([
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
...SCREEN_COMMANDS,
|
||||
NODE_DESKTOP_STREAM_COMMAND,
|
||||
]);
|
||||
const UNKNOWN_PLATFORM_COMMANDS = [
|
||||
...CAMERA_COMMANDS,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
isGatewayWorkAdmissionClosed,
|
||||
} from "../process/gateway-work-admission.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { NODE_DESKTOP_ATTACH_PATH } from "../shared/node-desktop-stream.js";
|
||||
import { resolveRuntimeServiceVersion } from "../version.js";
|
||||
import { resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
isControlUiPluginManagerRequest,
|
||||
} from "./control-ui-routing.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
import type { NodeDesktopStreamBroker } from "./desktop/node-stream-broker.js";
|
||||
import type { DesktopSessionRegistry } from "./desktop/session-registry.js";
|
||||
import {
|
||||
classifyGatewayProbePath,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
normalizePluginNodeCapabilityScopedUrl,
|
||||
type PluginNodeCapabilitySurface,
|
||||
} from "./plugin-node-capability.js";
|
||||
import type { GatewayRequestContext } from "./server-methods/types.js";
|
||||
import type { HooksRequestHandler } from "./server/hooks-request-handler.js";
|
||||
import {
|
||||
runWithGatewayHttpWorkAdmission,
|
||||
@@ -940,6 +943,8 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
/** Optional logger for error diagnostics. */
|
||||
log?: { warn: (msg: string) => void };
|
||||
desktopSessionRegistry?: DesktopSessionRegistry;
|
||||
nodeDesktopStreamBroker?: NodeDesktopStreamBroker;
|
||||
getGatewayRequestContext?: () => GatewayRequestContext | undefined;
|
||||
}) {
|
||||
const {
|
||||
httpServer,
|
||||
@@ -1119,6 +1124,21 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requestPath === NODE_DESKTOP_ATTACH_PATH) {
|
||||
const context = opts.getGatewayRequestContext?.();
|
||||
if (!opts.nodeDesktopStreamBroker || !context) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "node desktop attach unavailable");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (isGatewayWorkAdmissionClosed()) {
|
||||
writeGatewayUpgradeServiceUnavailable(socket, "Gateway websocket admission closed");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
await opts.nodeDesktopStreamBroker.handleUpgrade(req, socket, head, context.nodeRegistry);
|
||||
return;
|
||||
}
|
||||
// Plugin-owned upgrade routes have already had the opportunity to claim the socket.
|
||||
// Core Gateway control connections remain reachable while suspension is prepared.
|
||||
try {
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
claimControlUiDeviceAuthMigration,
|
||||
releaseControlUiDeviceAuthMigrationClaim,
|
||||
nodeRegistry,
|
||||
nodeDesktopService,
|
||||
workerEnvironmentService,
|
||||
hostDesktopService,
|
||||
workerEnvironmentStartup,
|
||||
@@ -163,6 +164,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
releaseControlUiDeviceAuthMigrationClaim: (deviceId: string) =>
|
||||
releaseControlUiDeviceAuthMigrationClaim(deviceId, { env: process.env }),
|
||||
nodeRegistry,
|
||||
...(nodeDesktopService ? { nodeDesktopService } : {}),
|
||||
...(workerEnvironmentService ? { workerEnvironmentService } : {}),
|
||||
...(hostDesktopService ? { hostDesktopService } : {}),
|
||||
...(workerEnvironmentStartup
|
||||
|
||||
@@ -100,6 +100,8 @@ export async function prepareGatewayLifecycle(params: {
|
||||
defaultWorkspaceDir,
|
||||
activeTaskCount,
|
||||
residentRegistry,
|
||||
desktopSessionRegistry,
|
||||
nodeDesktopStreamBroker,
|
||||
} = runtime;
|
||||
const completeControlUiDeviceAuthMigrationForEffectiveOperator = (
|
||||
device: EffectiveOperatorDeviceIdentity,
|
||||
@@ -155,6 +157,9 @@ export async function prepareGatewayLifecycle(params: {
|
||||
const unsubscribeSessionMessageEvents: GatewayRequestContext["unsubscribeSessionMessageEvents"] =
|
||||
(connId, sessionKey) => sessionMessageSubscribers.unsubscribe(connId, sessionKey);
|
||||
const restartRecoveryCandidates = new Map<string, RestartRecoveryCandidate>();
|
||||
const nodeDesktopServiceRef: {
|
||||
current?: import("./desktop/node-source.js").NodeDesktopService;
|
||||
} = {};
|
||||
const { createGatewayNodeSessionRuntime } = await import("./server-node-session-runtime.js");
|
||||
const {
|
||||
nodeRegistry,
|
||||
@@ -175,11 +180,25 @@ export async function prepareGatewayLifecycle(params: {
|
||||
nodePluginToolsEnabled: cfgAtStart.gateway?.nodes?.pluginTools?.enabled !== false,
|
||||
nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.allowSkills !== false,
|
||||
onPairingInvalidated: ({ nodeId, connId }) => {
|
||||
void nodeDesktopServiceRef.current?.stopNode(nodeId);
|
||||
upsertPresence(nodeId, { reason: "disconnect" });
|
||||
broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
|
||||
removeRemoteNodeInfoForConnection(nodeId, connId);
|
||||
},
|
||||
onPairingGenerationChanged: ({ nodeId }) => {
|
||||
void nodeDesktopServiceRef.current?.stopNode(nodeId);
|
||||
},
|
||||
});
|
||||
const nodeDesktopService =
|
||||
desktopSessionRegistry && nodeDesktopStreamBroker
|
||||
? (await import("./desktop/node-source.js")).createNodeDesktopService({
|
||||
getConfig: getRuntimeConfig,
|
||||
nodeRegistry,
|
||||
desktopRegistry: desktopSessionRegistry,
|
||||
streamBroker: nodeDesktopStreamBroker,
|
||||
})
|
||||
: undefined;
|
||||
nodeDesktopServiceRef.current = nodeDesktopService;
|
||||
const { createWatchNodeHttpRuntime } = await import("./watch-node-http.js");
|
||||
const watchNodeHttpRuntime = createWatchNodeHttpRuntime({
|
||||
nodeRegistry,
|
||||
@@ -633,6 +652,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
unsubscribeSessionMessageEvents,
|
||||
restartRecoveryCandidates,
|
||||
nodeRegistry,
|
||||
nodeDesktopService,
|
||||
nodePresenceTimers,
|
||||
nodeSendToSession,
|
||||
nodeSendToAllSubscribed,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { HostDesktopCredentialsRequiredError } from "../desktop/host-source-errors.js";
|
||||
import { createHostDesktopService } from "../desktop/host-source.js";
|
||||
import { NODE_DESKTOP_SERVICE_CONTEXT } from "../desktop/node-source-context.js";
|
||||
import { createDesktopSessionRegistry } from "../desktop/session-registry.js";
|
||||
import { environmentsHandlers } from "./environments.js";
|
||||
|
||||
@@ -160,12 +161,31 @@ describe("desktop gateway methods", () => {
|
||||
});
|
||||
|
||||
it("rejects unknown desktop source kinds before dispatch", async () => {
|
||||
const [ok, , error] = await invoke(
|
||||
"desktop.observe",
|
||||
{ source: { kind: "node", nodeId: "one" } },
|
||||
{},
|
||||
);
|
||||
const [ok, , error] = await invoke("desktop.observe", { source: { kind: "future" } }, {});
|
||||
expect(ok).toBe(false);
|
||||
expect(error.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||
});
|
||||
|
||||
it("forwards node credentials only to the paired-node desktop service", async () => {
|
||||
const observe = vi.fn(async () => ({
|
||||
transport: "rfb" as const,
|
||||
wsPath: "/desktop/observe?token=node",
|
||||
expiresAtMs: 42,
|
||||
control: false,
|
||||
auth: "vnc-password" as const,
|
||||
}));
|
||||
const credentials = { password: "memory-only-node-password" };
|
||||
const [ok, result] = await invoke(
|
||||
"desktop.observe",
|
||||
{ source: { kind: "node", nodeId: "node-1" }, credentials },
|
||||
{ [NODE_DESKTOP_SERVICE_CONTEXT]: { observe } },
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
expect(result).not.toHaveProperty("vncPassword");
|
||||
expect(observe).toHaveBeenCalledWith({
|
||||
nodeId: "node-1",
|
||||
control: false,
|
||||
credentials,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ 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_DESKTOP_STREAM_COMMAND } from "../../shared/node-desktop-stream.js";
|
||||
import type { WorkerEnvironmentServiceRecord } from "../worker-environments/service-contract.js";
|
||||
import type { WorkerEnvironmentRecord } from "../worker-environments/store.js";
|
||||
import { environmentsHandlers, summarizeWorkerEnvironment } from "./environments.js";
|
||||
@@ -237,6 +238,52 @@ describe("environment gateway methods", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks only connected, advertised, and explicitly allowed nodes as desktop sources", async () => {
|
||||
const context = mockContext();
|
||||
context.getRuntimeConfig = () =>
|
||||
({
|
||||
gateway: { nodes: { commands: { allow: [NODE_DESKTOP_STREAM_COMMAND] } } },
|
||||
}) as never;
|
||||
context.nodeRegistry.listConnectedForPairingStates = () =>
|
||||
[
|
||||
{
|
||||
nodeId: "node-desktop",
|
||||
connId: "conn-desktop",
|
||||
displayName: "Desktop Node",
|
||||
platform: "linux",
|
||||
deviceFamily: "Linux",
|
||||
caps: [],
|
||||
commands: [NODE_DESKTOP_STREAM_COMMAND],
|
||||
connectedAtMs: 123,
|
||||
},
|
||||
{
|
||||
nodeId: "node-without-command",
|
||||
connId: "conn-plain",
|
||||
displayName: "Plain Node",
|
||||
platform: "linux",
|
||||
deviceFamily: "Linux",
|
||||
caps: [],
|
||||
commands: [],
|
||||
connectedAtMs: 123,
|
||||
},
|
||||
] as never;
|
||||
const respond = vi.fn();
|
||||
await environmentsHandlers["environments.list"]?.({
|
||||
params: {},
|
||||
respond,
|
||||
context,
|
||||
} as never);
|
||||
const environments = respond.mock.calls[0]?.[1].environments as Array<{
|
||||
id: string;
|
||||
desktop?: boolean;
|
||||
}>;
|
||||
expect(environments.find((entry) => entry.id === "node:node-desktop")?.desktop).toBe(true);
|
||||
expect(
|
||||
environments.find((entry) => entry.id === "node:node-without-command")?.desktop,
|
||||
).toBeUndefined();
|
||||
expect(environments.find((entry) => entry.id === "node:node-offline")?.desktop).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends worker metadata with stable sessions and elapsed times", async () => {
|
||||
const service = workerService({
|
||||
list: vi.fn(() => [
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { listNodePairing } from "../../infra/device-pairing-node.js";
|
||||
import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../../shared/node-desktop-stream.js";
|
||||
import type { NodeListNode } from "../../shared/node-list-types.js";
|
||||
import { isHostDesktopCredentialsRequiredError } from "../desktop/host-source-errors.js";
|
||||
import { isDesktopCredentialsRequiredError } from "../desktop/host-source-errors.js";
|
||||
import { getNodeDesktopService } from "../desktop/node-source-context.js";
|
||||
import { createKnownNodeCatalog, listKnownNodes } from "../node-catalog.js";
|
||||
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
||||
import type { WorkerEnvironmentServiceRecord } from "../worker-environments/service-contract.js";
|
||||
import type { WorkerEnvironmentState } from "../worker-environments/state.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
@@ -57,11 +60,26 @@ function rejectInvalid(
|
||||
) {
|
||||
return respondInvalidParams({ respond, method, validator });
|
||||
}
|
||||
function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary {
|
||||
function summarizeNodeEnvironment(
|
||||
node: NodeListNode,
|
||||
config: Parameters<typeof resolveNodeCommandAllowlist>[0],
|
||||
): EnvironmentSummary {
|
||||
// Expose both declared capabilities and command names so older node
|
||||
// runtimes still advertise useful execution surfaces in one stable list.
|
||||
const capabilities = uniqueSortedStrings(node.caps, node.commands);
|
||||
const platform = node.platform?.trim();
|
||||
const desktop =
|
||||
node.connected === true &&
|
||||
isNodeCommandAllowed({
|
||||
command: NODE_DESKTOP_STREAM_COMMAND,
|
||||
declaredCommands: node.commands,
|
||||
allowlist: resolveNodeCommandAllowlist(config, {
|
||||
platform: node.platform,
|
||||
deviceFamily: node.deviceFamily,
|
||||
commands: node.commands,
|
||||
approvedCommands: node.commands,
|
||||
}),
|
||||
}).ok;
|
||||
return {
|
||||
id: `node:${node.nodeId}`,
|
||||
type: "node",
|
||||
@@ -70,6 +88,7 @@ function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary {
|
||||
...(platform ? { platform } : {}),
|
||||
sessionHost: false,
|
||||
trust: "persistent",
|
||||
...(desktop ? { desktop: true } : {}),
|
||||
...(capabilities.length > 0 ? { capabilities } : {}),
|
||||
};
|
||||
}
|
||||
@@ -121,11 +140,15 @@ async function listEnvironments(context: GatewayRequestContext): Promise<Environ
|
||||
pairedNodes: nodes.paired,
|
||||
connectedNodes: context.nodeRegistry.listConnectedForPairingStates(currentPairingStates),
|
||||
});
|
||||
const config = context.getRuntimeConfig();
|
||||
const gateway =
|
||||
context.getRuntimeConfig().desktop?.host?.enabled === true
|
||||
config.desktop?.host?.enabled === true
|
||||
? { ...GATEWAY_ENVIRONMENT, desktop: true }
|
||||
: GATEWAY_ENVIRONMENT;
|
||||
return [gateway, ...listKnownNodes(catalog).map(summarizeNodeEnvironment)];
|
||||
return [
|
||||
gateway,
|
||||
...listKnownNodes(catalog).map((node) => summarizeNodeEnvironment(node, config)),
|
||||
];
|
||||
}
|
||||
function listWorkerEnvironments(context: GatewayRequestContext): WorkerEnvironmentServiceRecord[] {
|
||||
try {
|
||||
@@ -207,7 +230,7 @@ async function respondDesktopObserve(params: {
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isHostDesktopCredentialsRequiredError(error)) {
|
||||
if (isDesktopCredentialsRequiredError(error)) {
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -234,6 +257,54 @@ async function respondDesktopObserve(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.request.source.kind === "node") {
|
||||
const service = getNodeDesktopService(params.context);
|
||||
if (!service) {
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"node desktop is disabled; explicitly allow desktop.stream, then restart the gateway",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
params.respond(
|
||||
true,
|
||||
await service.observe({
|
||||
nodeId: params.request.source.nodeId,
|
||||
control: params.request.control ?? false,
|
||||
...("credentials" in params.request && params.request.credentials
|
||||
? { credentials: params.request.credentials }
|
||||
: {}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDesktopCredentialsRequiredError(error)) {
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, error.message, {
|
||||
details: { code: error.detailCode, auth: error.auth },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
error instanceof Error ? error.message : "node desktop observe unavailable",
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const service = params.context.workerEnvironmentService;
|
||||
if (!service) {
|
||||
params.respond(
|
||||
|
||||
@@ -72,6 +72,25 @@ function registerNode(
|
||||
}
|
||||
|
||||
describe("gateway node session runtime", () => {
|
||||
test("publishes pairing-generation transitions to lifecycle consumers", () => {
|
||||
const onPairingGenerationChanged = vi.fn();
|
||||
const runtime = createGatewayNodeSessionRuntime({
|
||||
broadcast: vi.fn(),
|
||||
onPairingGenerationChanged,
|
||||
sessionEventSubscribers: createSessionEventSubscriberRegistry(),
|
||||
sessionMessageSubscribers: createSessionMessageSubscriberRegistry(),
|
||||
});
|
||||
registerNode(runtime, "conn-original", "generation-a", []);
|
||||
registerNode(runtime, "conn-replacement", "generation-b", []);
|
||||
|
||||
expect(onPairingGenerationChanged).toHaveBeenCalledWith({
|
||||
nodeId: "node-a",
|
||||
previousPairingGeneration: "generation-a",
|
||||
nextPairingGeneration: "generation-b",
|
||||
preserveSessionState: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("forwards subscribed payload json without parsing it again", async () => {
|
||||
const frames: string[] = [];
|
||||
const runtime = createRuntime(async () => "generation-a");
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createGatewayNodeSessionRuntime(params: {
|
||||
resolveCurrentPairingState?: NodeRegistryOptions["resolveCurrentPairingState"];
|
||||
isPairingStateCurrent?: NodeRegistryOptions["isPairingStateCurrent"];
|
||||
onPairingInvalidated?: NodeRegistryOptions["onPairingInvalidated"];
|
||||
onPairingGenerationChanged?: NodeRegistryOptions["onPairingGenerationChanged"];
|
||||
sessionEventSubscribers: SessionEventSubscriberRegistry;
|
||||
sessionMessageSubscribers: SessionMessageSubscriberRegistry;
|
||||
}) {
|
||||
@@ -46,6 +47,7 @@ export function createGatewayNodeSessionRuntime(params: {
|
||||
...change,
|
||||
preserveSubscriptions: change.preserveSessionState,
|
||||
});
|
||||
params.onPairingGenerationChanged?.(change);
|
||||
},
|
||||
});
|
||||
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { upsertPresence } from "../infra/system-presence.js";
|
||||
import { resolveUserProfileId } from "../state/user-profiles.js";
|
||||
import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js";
|
||||
import { NODE_DESKTOP_SERVICE_CONTEXT } from "./desktop/node-source-context.js";
|
||||
import type { GatewayServerLiveState } from "./server-live-state.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
|
||||
import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js";
|
||||
@@ -79,6 +80,7 @@ type GatewayRequestContextParams = {
|
||||
scopes: string[];
|
||||
}) => void;
|
||||
nodeRegistry: GatewayRequestContext["nodeRegistry"];
|
||||
nodeDesktopService?: import("./desktop/node-source.js").NodeDesktopService;
|
||||
workerEnvironmentService?: GatewayRequestContext["workerEnvironmentService"];
|
||||
hostDesktopService?: GatewayRequestContext["hostDesktopService"];
|
||||
workerSessionPlacementService?: GatewayRequestContext["workerSessionPlacementService"];
|
||||
@@ -365,6 +367,9 @@ export function createGatewayRequestContext(
|
||||
releaseControlUiDeviceAuthMigrationClaim: params.releaseControlUiDeviceAuthMigrationClaim,
|
||||
completeControlUiDeviceAuthMigration: params.completeControlUiDeviceAuthMigration,
|
||||
nodeRegistry: params.nodeRegistry,
|
||||
...(params.nodeDesktopService
|
||||
? { [NODE_DESKTOP_SERVICE_CONTEXT]: params.nodeDesktopService }
|
||||
: {}),
|
||||
...(params.workerEnvironmentService
|
||||
? { workerEnvironmentService: params.workerEnvironmentService }
|
||||
: {}),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { runtimeForLogger } from "../logging/subsystem.js";
|
||||
import { isGatewayDraining } from "../process/command-queue.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../shared/node-desktop-stream.js";
|
||||
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import { resolveGatewayAuth } from "./auth.js";
|
||||
import { createDesktopSessionRegistry } from "./desktop/session-registry.js";
|
||||
@@ -117,13 +118,29 @@ export async function prepareGatewayKernelState(params: {
|
||||
Boolean(workerEnvironmentStartup?.hasNonlocalPlacementRecords);
|
||||
const hostDesktopConfig = gatewayPluginConfigAtStart.desktop?.host;
|
||||
const hostDesktopEnabled = hostDesktopConfig?.enabled === true;
|
||||
const nodeCommandConfig = gatewayPluginConfigAtStart.gateway?.nodes?.commands;
|
||||
const nodeDesktopObserveAvailable =
|
||||
(nodeCommandConfig?.allow ?? []).some(
|
||||
(command) => command.trim() === NODE_DESKTOP_STREAM_COMMAND,
|
||||
) &&
|
||||
!(nodeCommandConfig?.deny ?? []).some(
|
||||
(command) => command.trim() === NODE_DESKTOP_STREAM_COMMAND,
|
||||
);
|
||||
const workerGatewayEndpoint = {
|
||||
resolve: (() => undefined) as () => { host: "127.0.0.1" | "::1"; port: number } | undefined,
|
||||
};
|
||||
const desktopSessionRegistry =
|
||||
shouldStartWorkerEnvironmentService || hostDesktopEnabled
|
||||
shouldStartWorkerEnvironmentService || hostDesktopEnabled || nodeDesktopObserveAvailable
|
||||
? createDesktopSessionRegistry()
|
||||
: undefined;
|
||||
const nodeDesktopStreamBroker = nodeDesktopObserveAvailable
|
||||
? (
|
||||
await startupTrace.measure(
|
||||
"node-desktop.runtime-import",
|
||||
() => import("./desktop/node-stream-broker.js"),
|
||||
)
|
||||
).createNodeDesktopStreamBroker()
|
||||
: undefined;
|
||||
const hostDesktopService =
|
||||
hostDesktopConfig && hostDesktopEnabled && desktopSessionRegistry
|
||||
? (
|
||||
@@ -181,7 +198,8 @@ export async function prepareGatewayKernelState(params: {
|
||||
: undefined;
|
||||
const workerDesktopObserveAvailable =
|
||||
Boolean(workerEnvironmentService) && gatewayPluginConfigAtStart.cloudWorkers?.desktop === true;
|
||||
const desktopObserveAvailable = workerDesktopObserveAvailable || Boolean(hostDesktopService);
|
||||
const desktopObserveAvailable =
|
||||
workerDesktopObserveAvailable || nodeDesktopObserveAvailable || Boolean(hostDesktopService);
|
||||
const channelLogs = Object.fromEntries(
|
||||
listGatewayStartupChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
|
||||
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
|
||||
@@ -440,6 +458,7 @@ export async function prepareGatewayKernelState(params: {
|
||||
(await watchNodeRequestHandler.current?.(req, res)) ?? false,
|
||||
workerIngressEnabled: Boolean(workerEnvironmentService),
|
||||
desktopSessionRegistry,
|
||||
nodeDesktopStreamBroker,
|
||||
clients: connectionState.clients,
|
||||
});
|
||||
const {
|
||||
@@ -473,6 +492,8 @@ export async function prepareGatewayKernelState(params: {
|
||||
workerDesktopObserveAvailable,
|
||||
desktopObserveAvailable,
|
||||
desktopSessionRegistry,
|
||||
nodeDesktopObserveAvailable,
|
||||
nodeDesktopStreamBroker,
|
||||
hostDesktopService,
|
||||
channelLogs,
|
||||
channelRuntimeEnvs,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
import type { NodeDesktopStreamBroker } from "./desktop/node-stream-broker.js";
|
||||
import type { DesktopSessionRegistry } from "./desktop/session-registry.js";
|
||||
import type { HooksConfigResolved } from "./hooks.js";
|
||||
import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js";
|
||||
@@ -120,6 +121,7 @@ export async function createGatewayHttpTransport(params: {
|
||||
handleWatchNodeRequest?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
workerIngressEnabled?: boolean;
|
||||
desktopSessionRegistry?: DesktopSessionRegistry;
|
||||
nodeDesktopStreamBroker?: NodeDesktopStreamBroker;
|
||||
clients: Set<GatewayWsClient>;
|
||||
}): Promise<{
|
||||
httpServer: HttpServer;
|
||||
@@ -307,6 +309,8 @@ export async function createGatewayHttpTransport(params: {
|
||||
workerIngressEnabled: params.workerIngressEnabled,
|
||||
log: params.log,
|
||||
desktopSessionRegistry: params.desktopSessionRegistry,
|
||||
nodeDesktopStreamBroker: params.nodeDesktopStreamBroker,
|
||||
getGatewayRequestContext: params.getGatewayRequestContext,
|
||||
});
|
||||
gatewayHttpServers.push(httpServer);
|
||||
httpServers.push(httpServer);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
WorkerDesktopEndpoint,
|
||||
WorkerSshEndpoint,
|
||||
} from "../../plugins/types.js";
|
||||
import type { RfbAttachment } from "../desktop/attachment.js";
|
||||
import type { DesktopRfbAttachment } from "../desktop/attachment.js";
|
||||
import {
|
||||
createDesktopSessionRegistry,
|
||||
DesktopSessionStaleOwnerError,
|
||||
@@ -45,7 +45,7 @@ type DesktopAcquireRequest = {
|
||||
resolveIdentity: WorkerSshIdentityResolver;
|
||||
};
|
||||
|
||||
type DesktopAcquireResult = { attachment: RfbAttachment; vncPassword?: string };
|
||||
type DesktopAcquireResult = { attachment: DesktopRfbAttachment; vncPassword?: string };
|
||||
|
||||
type DesktopAppLaunchEntry = {
|
||||
environmentId: string;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { invokeNodeDesktopStream } from "./desktop-stream-command.js";
|
||||
|
||||
const TICKET = "a".repeat(48);
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
||||
});
|
||||
|
||||
describe("node desktop stream command", () => {
|
||||
it("refuses a caller-selected RFB target before dialing", async () => {
|
||||
await expect(
|
||||
invokeNodeDesktopStream({
|
||||
paramsJSON: JSON.stringify({
|
||||
ticket: TICKET,
|
||||
attachPath: `/node-desktop/attach?ticket=${TICKET}`,
|
||||
target: { host: "192.0.2.10", port: 5900 },
|
||||
}),
|
||||
gatewayUrl: "ws://127.0.0.1:1",
|
||||
config: { enabled: true },
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).rejects.toThrow("unsupported fields");
|
||||
});
|
||||
|
||||
it("refuses an attach path that changes the connected gateway origin", async () => {
|
||||
await expect(
|
||||
invokeNodeDesktopStream({
|
||||
paramsJSON: JSON.stringify({
|
||||
ticket: TICKET,
|
||||
attachPath: `//attacker.example/node-desktop/attach?ticket=${TICKET}`,
|
||||
}),
|
||||
gatewayUrl: "ws://127.0.0.1:1",
|
||||
config: { enabled: true },
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).rejects.toThrow("ticket and attachPath required");
|
||||
});
|
||||
|
||||
it("tears down both splice sockets when the invoke is cancelled", async () => {
|
||||
const rfbPeers = new Set<net.Socket>();
|
||||
const rfbServer = net.createServer((socket) => {
|
||||
rfbPeers.add(socket);
|
||||
socket.once("close", () => rfbPeers.delete(socket));
|
||||
socket.write(Buffer.from("RFB 003.008\n", "ascii"));
|
||||
socket.once("data", () => socket.write(Buffer.from([1, 2])));
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
rfbServer.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const rfbAddress = rfbServer.address();
|
||||
if (!rfbAddress || typeof rfbAddress === "string") {
|
||||
throw new Error("expected RFB test address");
|
||||
}
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
for (const peer of rfbPeers) {
|
||||
peer.destroy();
|
||||
}
|
||||
rfbServer.close(() => resolve());
|
||||
}),
|
||||
);
|
||||
|
||||
const httpServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
let streamClosed = false;
|
||||
wss.on("connection", (ws) => {
|
||||
ws.once("close", () => {
|
||||
streamClosed = true;
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const gatewayAddress = httpServer.address();
|
||||
if (!gatewayAddress || typeof gatewayAddress === "string") {
|
||||
throw new Error("expected Gateway test address");
|
||||
}
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => httpServer.close(() => resolve()));
|
||||
}),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const emitStatus = vi.fn(async () => undefined);
|
||||
const running = invokeNodeDesktopStream({
|
||||
paramsJSON: JSON.stringify({
|
||||
ticket: TICKET,
|
||||
attachPath: `/node-desktop/attach?ticket=${TICKET}`,
|
||||
}),
|
||||
gatewayUrl: `ws://127.0.0.1:${gatewayAddress.port}`,
|
||||
config: { enabled: true, port: rfbAddress.port },
|
||||
signal: controller.signal,
|
||||
emitStatus,
|
||||
});
|
||||
await vi.waitFor(() => expect(emitStatus).toHaveBeenCalledWith("desktop stream attached\n"));
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => expect(streamClosed).toBe(true));
|
||||
await vi.waitFor(() => expect(rfbPeers.size).toBe(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import type { TLSSocket } from "node:tls";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { WebSocket, type ClientOptions, type RawData } from "ws";
|
||||
import type { DesktopHostConfig } from "../config/types.desktop.js";
|
||||
import { classifyRfbSecurity, probeRfbServer } from "../gateway/desktop/rfb-probe.js";
|
||||
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
|
||||
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
|
||||
import { NODE_DESKTOP_ATTACH_PATH } from "../shared/node-desktop-stream.js";
|
||||
|
||||
const DEFAULT_DESKTOP_PORT = 5900;
|
||||
const PROBE_TIMEOUT_MS = 1_500;
|
||||
const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
||||
const PAUSE_BUFFERED_BYTES = 4 * 1024 * 1024;
|
||||
const RESUME_CHECK_MS = 25;
|
||||
const TICKET_PATTERN = /^[a-f0-9]{48}$/u;
|
||||
|
||||
type NodeDesktopStreamCommandParams = {
|
||||
ticket: string;
|
||||
attachPath: string;
|
||||
};
|
||||
|
||||
type NodeDesktopStreamTarget = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
function decodeDesktopStreamParams(raw?: string | null): NodeDesktopStreamCommandParams {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
throw new Error("INVALID_REQUEST: desktop stream params malformed JSON");
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("INVALID_REQUEST: desktop stream params required");
|
||||
}
|
||||
const ticket = typeof value.ticket === "string" ? value.ticket.trim() : "";
|
||||
const attachPath = typeof value.attachPath === "string" ? value.attachPath.trim() : "";
|
||||
if (
|
||||
!TICKET_PATTERN.test(ticket) ||
|
||||
attachPath !== `${NODE_DESKTOP_ATTACH_PATH}?ticket=${ticket}`
|
||||
) {
|
||||
throw new Error("INVALID_REQUEST: desktop stream ticket and attachPath required");
|
||||
}
|
||||
const attachUrl = new URL(attachPath, "http://127.0.0.1");
|
||||
if (attachUrl.searchParams.get("ticket") !== ticket) {
|
||||
throw new Error("INVALID_REQUEST: desktop stream ticket does not match attachPath");
|
||||
}
|
||||
if (Object.keys(value).some((key) => key !== "ticket" && key !== "attachPath")) {
|
||||
throw new Error("INVALID_REQUEST: desktop stream params contain unsupported fields");
|
||||
}
|
||||
return { ticket, attachPath };
|
||||
}
|
||||
|
||||
function websocketDataBuffer(data: RawData): Buffer {
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(data);
|
||||
}
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
function attachWebSocketUrl(gatewayUrl: string, attachPath: string): string {
|
||||
const gateway = new URL(gatewayUrl);
|
||||
const url = new URL(attachPath, gateway);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error("desktop stream gateway URL must use WebSocket transport");
|
||||
}
|
||||
if (url.origin !== gateway.origin || url.pathname !== NODE_DESKTOP_ATTACH_PATH) {
|
||||
throw new Error("desktop stream attachPath must stay on the connected gateway");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function assertTlsSocketFingerprint(socket: TLSSocket, expectedRaw: string): void {
|
||||
const expected = normalizeFingerprint(expectedRaw);
|
||||
const actual = normalizeFingerprint(socket.getPeerCertificate().fingerprint256 ?? "");
|
||||
if (!expected || !actual || actual !== expected) {
|
||||
throw new Error("gateway TLS fingerprint mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function createPinnedRequestFinisher(
|
||||
expected: string,
|
||||
): NonNullable<ClientOptions["finishRequest"]> {
|
||||
return (request) => {
|
||||
request.once("socket", (socket) => {
|
||||
const tlsSocket = socket as TLSSocket;
|
||||
tlsSocket.once("secureConnect", () => {
|
||||
try {
|
||||
assertTlsSocketFingerprint(tlsSocket, expected);
|
||||
request.end();
|
||||
} catch (error) {
|
||||
request.destroy(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function websocketOptions(url: string, tlsFingerprint?: string): ClientOptions {
|
||||
if (!url.startsWith("wss:") || !tlsFingerprint?.trim()) {
|
||||
return { maxPayload: MAX_PAYLOAD_BYTES };
|
||||
}
|
||||
return {
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
rejectUnauthorized: false,
|
||||
finishRequest: createPinnedRequestFinisher(tlsFingerprint),
|
||||
};
|
||||
}
|
||||
|
||||
function assertGatewayTlsFingerprint(ws: WebSocket, expectedRaw?: string): void {
|
||||
if (!expectedRaw?.trim()) {
|
||||
return;
|
||||
}
|
||||
const expected = normalizeFingerprint(expectedRaw);
|
||||
const socket = (
|
||||
ws as WebSocket & {
|
||||
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
|
||||
}
|
||||
)["_socket"];
|
||||
const actual = normalizeFingerprint(socket?.getPeerCertificate?.().fingerprint256 ?? "");
|
||||
if (!expected || !actual || actual !== expected) {
|
||||
throw new Error("gateway TLS fingerprint mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
async function readVncPassword(passwordFile?: string): Promise<string | undefined> {
|
||||
if (!passwordFile) {
|
||||
return undefined;
|
||||
}
|
||||
const password = (await fs.readFile(passwordFile, "utf8")).replace(/[\r\n]+$/u, "");
|
||||
if (!password) {
|
||||
throw new Error("desktop.host.passwordFile is empty");
|
||||
}
|
||||
registerSecretValueForRedaction(password);
|
||||
return password;
|
||||
}
|
||||
|
||||
async function waitForSocketConnect(socket: net.Socket): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWebSocketOpen(ws: WebSocket): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendAttachMetadata(
|
||||
ws: WebSocket,
|
||||
metadata: { auth: "vnc-password" | "ard-account"; vncPassword?: string },
|
||||
): Promise<void> {
|
||||
const buffer = Buffer.from(JSON.stringify(metadata), "utf8");
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.send(buffer, { binary: true }, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function createDesktopStreamSplice(params: { rfbSocket: net.Socket; ws: WebSocket }) {
|
||||
let resumeTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let settled = false;
|
||||
let finish!: (error?: Error) => void;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
finish = (error?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearInterval(resumeTimer);
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
params.ws.on("message", (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
finish(new Error("gateway sent non-binary desktop stream data"));
|
||||
return;
|
||||
}
|
||||
if (!params.rfbSocket.write(websocketDataBuffer(data))) {
|
||||
params.ws.pause();
|
||||
params.rfbSocket.once("drain", () => params.ws.resume());
|
||||
}
|
||||
});
|
||||
params.rfbSocket.on("data", (chunk) => {
|
||||
if (params.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
params.ws.send(chunk, { binary: true }, (error) => error && finish(error));
|
||||
if (params.ws.bufferedAmount <= PAUSE_BUFFERED_BYTES || resumeTimer) {
|
||||
return;
|
||||
}
|
||||
params.rfbSocket.pause();
|
||||
resumeTimer = setInterval(() => {
|
||||
if (params.ws.bufferedAmount <= PAUSE_BUFFERED_BYTES) {
|
||||
clearInterval(resumeTimer);
|
||||
resumeTimer = undefined;
|
||||
params.rfbSocket.resume();
|
||||
}
|
||||
}, RESUME_CHECK_MS);
|
||||
resumeTimer.unref?.();
|
||||
});
|
||||
params.ws.once("close", () => finish());
|
||||
params.ws.once("error", (error) => finish(error));
|
||||
params.rfbSocket.once("close", () => finish());
|
||||
params.rfbSocket.once("error", (error) => finish(error));
|
||||
});
|
||||
void done.catch(() => undefined);
|
||||
return {
|
||||
done,
|
||||
start() {
|
||||
if (params.rfbSocket.destroyed || params.ws.readyState !== WebSocket.OPEN) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
params.rfbSocket.resume();
|
||||
params.ws.resume();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Splices a node-local loopback RFB socket to a ticket-authenticated Gateway WebSocket. */
|
||||
async function runNodeDesktopStreamCommand(params: {
|
||||
command: NodeDesktopStreamCommandParams;
|
||||
gatewayUrl: string;
|
||||
gatewayTlsFingerprint?: string;
|
||||
target: NodeDesktopStreamTarget;
|
||||
passwordFile?: string;
|
||||
signal: AbortSignal;
|
||||
emitStatus?: (status: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (params.target.host !== "127.0.0.1") {
|
||||
throw new Error("desktop stream target must be loopback");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(params.target.port) ||
|
||||
params.target.port < 1 ||
|
||||
params.target.port > 65535
|
||||
) {
|
||||
throw new Error("desktop stream target port is invalid");
|
||||
}
|
||||
void params.emitStatus?.("probing local RFB server\n").catch(() => undefined);
|
||||
const probe = await probeRfbServer({
|
||||
host: "127.0.0.1",
|
||||
port: params.target.port,
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
if (probe.kind !== "rfb") {
|
||||
throw new Error(
|
||||
probe.kind === "not-rfb"
|
||||
? "desktop stream target is not an RFB server"
|
||||
: "desktop stream loopback RFB server is unavailable",
|
||||
);
|
||||
}
|
||||
const auth = classifyRfbSecurity(probe.securityTypes);
|
||||
if (auth === "none") {
|
||||
throw new Error("refusing unauthenticated loopback RFB server");
|
||||
}
|
||||
if (auth === "unsupported") {
|
||||
throw new Error("loopback RFB server security is unsupported");
|
||||
}
|
||||
const vncPassword =
|
||||
auth === "vnc-password" ? await readVncPassword(params.passwordFile) : undefined;
|
||||
if (params.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rfbSocket = net.createConnection(params.target.port, "127.0.0.1");
|
||||
// The RFB server can send its banner as soon as TCP connects. Pause until the
|
||||
// attach metadata is accepted so no stateful handshake bytes are lost.
|
||||
rfbSocket.pause();
|
||||
const wsUrl = attachWebSocketUrl(params.gatewayUrl, params.command.attachPath);
|
||||
const ws = new WebSocket(wsUrl, websocketOptions(wsUrl, params.gatewayTlsFingerprint));
|
||||
let aborted: boolean = params.signal.aborted;
|
||||
let resolveAbort!: () => void;
|
||||
const abort = new Promise<void>((resolve) => {
|
||||
resolveAbort = resolve;
|
||||
});
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
rfbSocket.destroy();
|
||||
ws.terminate();
|
||||
resolveAbort();
|
||||
};
|
||||
params.signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (aborted) {
|
||||
onAbort();
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.all([waitForSocketConnect(rfbSocket), waitForWebSocketOpen(ws)]),
|
||||
abort,
|
||||
]);
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
assertGatewayTlsFingerprint(ws, params.gatewayTlsFingerprint);
|
||||
ws.pause();
|
||||
const splice = createDesktopStreamSplice({ rfbSocket, ws });
|
||||
await sendAttachMetadata(ws, { auth, ...(vncPassword ? { vncPassword } : {}) });
|
||||
void params.emitStatus?.("desktop stream attached\n").catch(() => undefined);
|
||||
splice.start();
|
||||
await splice.done;
|
||||
} catch (error) {
|
||||
if (!aborted) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
params.signal.removeEventListener("abort", onAbort);
|
||||
rfbSocket.destroy();
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the built-in command against the node-local desktop configuration. */
|
||||
export async function invokeNodeDesktopStream(params: {
|
||||
paramsJSON?: string | null;
|
||||
gatewayUrl?: string;
|
||||
gatewayTlsFingerprint?: string;
|
||||
config?: DesktopHostConfig;
|
||||
signal?: AbortSignal;
|
||||
emitStatus?: (status: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (!params.gatewayUrl || !params.signal) {
|
||||
throw new Error("desktop stream gateway connection is unavailable");
|
||||
}
|
||||
if (params.config?.enabled !== true) {
|
||||
throw new Error("desktop host streaming is disabled on this node");
|
||||
}
|
||||
const command = decodeDesktopStreamParams(params.paramsJSON);
|
||||
await runNodeDesktopStreamCommand({
|
||||
command,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
...(params.gatewayTlsFingerprint
|
||||
? { gatewayTlsFingerprint: params.gatewayTlsFingerprint }
|
||||
: {}),
|
||||
target: {
|
||||
host: "127.0.0.1",
|
||||
port: params.config.port ?? DEFAULT_DESKTOP_PORT,
|
||||
},
|
||||
...(params.config.passwordFile ? { passwordFile: params.config.passwordFile } : {}),
|
||||
signal: params.signal,
|
||||
...(params.emitStatus ? { emitStatus: params.emitStatus } : {}),
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type GatewayCandidateConnectionParams = {
|
||||
candidates: readonly NodeHostGatewayConfig[];
|
||||
clientOptions: CandidateConnectionOptions;
|
||||
onEvent: (event: GatewayCandidateEvent) => void;
|
||||
onHelloOk: (hello: GatewayCandidateHello, url: string) => void;
|
||||
onHelloOk: (hello: GatewayCandidateHello, url: string, tlsFingerprint?: string) => void;
|
||||
onConnectError: (error: Error) => void;
|
||||
onReconnectPaused: (info: GatewayReconnectPausedInfo) => void;
|
||||
onClose: (code: number, reason: string, info?: GatewayClientCloseInfo) => void;
|
||||
@@ -84,7 +84,7 @@ export function createNodeHostGatewayCandidateConnection(params: GatewayCandidat
|
||||
winnerSelected = true;
|
||||
params.onWinningCandidate(candidate);
|
||||
}
|
||||
params.onHelloOk(hello, url);
|
||||
params.onHelloOk(hello, url, candidate.tlsFingerprint);
|
||||
},
|
||||
onConnectError: (error) => {
|
||||
if (currentCandidateIndex === candidateIndex) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DesktopHostConfig } from "../config/types.desktop.js";
|
||||
import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js";
|
||||
import type { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
@@ -25,6 +26,10 @@ export type NodeHostInvokeRuntime = {
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
installedAppsPlatform?: NodeJS.Platform;
|
||||
scanInstalledApps?: typeof scanInstalledApps;
|
||||
gatewayUrl?: string;
|
||||
gatewayTlsFingerprint?: string;
|
||||
desktopHostConfig?: DesktopHostConfig;
|
||||
emitProgress?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type ClaudeCliNodeInvokeDeps = Pick<
|
||||
|
||||
@@ -43,8 +43,10 @@ import {
|
||||
} from "../infra/node-commands.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../shared/node-desktop-stream.js";
|
||||
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { invokeNodeDesktopStream } from "./desktop-stream-command.js";
|
||||
import {
|
||||
handleClaudeCliNodeInvoke,
|
||||
type NodeHostInvokeRuntime,
|
||||
@@ -617,6 +619,27 @@ async function dispatchInvoke(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === NODE_DESKTOP_STREAM_COMMAND) {
|
||||
try {
|
||||
await invokeNodeDesktopStream({
|
||||
paramsJSON: frame.paramsJSON,
|
||||
gatewayUrl: runtime.gatewayUrl,
|
||||
gatewayTlsFingerprint: runtime.gatewayTlsFingerprint,
|
||||
config: runtime.desktopHostConfig,
|
||||
signal: runtime.signal,
|
||||
emitStatus: runtime.emitProgress,
|
||||
});
|
||||
await sendJsonPayloadResult(client, frame, { status: "closed" });
|
||||
} catch (error) {
|
||||
await sendErrorResult(
|
||||
client,
|
||||
frame,
|
||||
"UNAVAILABLE",
|
||||
error instanceof Error ? error.message : "desktop stream unavailable",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "system.execApprovals.get") {
|
||||
try {
|
||||
const snapshot = await ensureExecApprovalsSnapshot();
|
||||
|
||||
@@ -61,6 +61,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleInput: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelAll: vi.fn(),
|
||||
updateGatewayConnection: vi.fn(),
|
||||
close: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -504,8 +504,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
void activeRuntime.invoke(payload);
|
||||
}
|
||||
},
|
||||
onHelloOk: (hello, url) => {
|
||||
onHelloOk: (hello, url, tlsFingerprint) => {
|
||||
writeStderrLine(`node host gateway connected: ${url}`);
|
||||
activeRuntime.updateGatewayConnection({ url, ...(tlsFingerprint ? { tlsFingerprint } : {}) });
|
||||
gatewayConnectionGeneration += 1;
|
||||
gatewayHelloReceived = true;
|
||||
connectedGatewayProtocol = hello.protocol;
|
||||
@@ -533,6 +534,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
retireGatewayConnection();
|
||||
activeRuntime.updateGatewayConnection();
|
||||
activeRuntime.cancelAll();
|
||||
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NODE_DEVICE_APPS_COMMAND } from "../infra/node-commands.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../shared/node-desktop-stream.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { listRegisteredNodeHostCapsAndCommands } from "./plugin-node-host.js";
|
||||
import { prepareNodeHostRuntime } from "./runtime.js";
|
||||
@@ -8,6 +9,8 @@ import { prepareNodeHostRuntime } from "./runtime.js";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
closeMcp: vi.fn(async () => undefined),
|
||||
handleInvoke: vi.fn(async () => undefined),
|
||||
progressStartHeartbeats: vi.fn(),
|
||||
progressWrite: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/path-env.js", () => ({
|
||||
@@ -29,8 +32,8 @@ vi.mock("./mcp.js", () => ({
|
||||
|
||||
vi.mock("./node-invoke-progress.js", () => ({
|
||||
createNodeInvokeProgressWriter: vi.fn(() => ({
|
||||
startHeartbeats: vi.fn(),
|
||||
write: vi.fn(async () => undefined),
|
||||
startHeartbeats: mocks.progressStartHeartbeats,
|
||||
write: mocks.progressWrite,
|
||||
stop: vi.fn(),
|
||||
flush: vi.fn(async () => undefined),
|
||||
})),
|
||||
@@ -180,6 +183,40 @@ describe("node-host invocation cancellation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("node-host desktop manifest", () => {
|
||||
it("advertises desktop.stream only when the node-local desktop is enabled", async () => {
|
||||
const disabled = await prepareNodeHostRuntime({
|
||||
config: {},
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "linux",
|
||||
});
|
||||
expect(disabled.manifest.commands).not.toContain(NODE_DESKTOP_STREAM_COMMAND);
|
||||
|
||||
const enabled = await prepareNodeHostRuntime({
|
||||
config: { desktop: { host: { enabled: true } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "linux",
|
||||
});
|
||||
expect(enabled.manifest.commands).toContain(NODE_DESKTOP_STREAM_COMMAND);
|
||||
});
|
||||
|
||||
it("emits desktop statuses without control-channel heartbeats", async () => {
|
||||
const runtime = await startRuntime();
|
||||
await runtime.invoke({ ...frame, command: NODE_DESKTOP_STREAM_COMMAND });
|
||||
|
||||
expect(mocks.progressStartHeartbeats).not.toHaveBeenCalled();
|
||||
const lastCall = mocks.handleInvoke.mock.calls.at(-1) as unknown[] | undefined;
|
||||
const invokeRuntime = lastCall?.[4] as
|
||||
| {
|
||||
emitProgress?: (text: string) => Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
await invokeRuntime?.emitProgress?.("attached\n");
|
||||
expect(mocks.progressWrite).toHaveBeenCalledWith("attached\n");
|
||||
await runtime.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("node-host invoke input dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { logDebug } from "../logger.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
|
||||
import { BoundedBuffer } from "../shared/bounded-buffer.js";
|
||||
import { NODE_DESKTOP_STREAM_COMMAND } from "../shared/node-desktop-stream.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js";
|
||||
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
|
||||
@@ -61,6 +62,7 @@ type ActiveNodeHostRuntime = {
|
||||
handleInput(invokeId: string, seq: number, payloadJSON: string): void;
|
||||
cancel(invokeId: string): void;
|
||||
cancelAll(): void;
|
||||
updateGatewayConnection(connection?: { url: string; tlsFingerprint?: string }): void;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -252,6 +254,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
const platform = params?.platform ?? process.platform;
|
||||
const installedAppsSharingEnabled =
|
||||
platform === "darwin" && params?.installedAppsSharingEnabled === true;
|
||||
const desktopStreamingEnabled =
|
||||
(platform === "darwin" || platform === "linux" || platform === "win32") &&
|
||||
config.desktop?.host?.enabled === true;
|
||||
const availabilityContext = { config, env };
|
||||
const resolvePluginNodeHost = () =>
|
||||
listRegisteredNodeHostCapsAndCommands(availabilityContext, {
|
||||
@@ -281,6 +286,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_TERMINAL_UPLOAD_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...(desktopStreamingEnabled ? [NODE_DESKTOP_STREAM_COMMAND] : []),
|
||||
...(installedAppsSharingEnabled ? [NODE_DEVICE_APPS_COMMAND] : []),
|
||||
...(claudePath ? [NODE_AGENT_CLI_CLAUDE_RUN_COMMAND] : []),
|
||||
...pluginManifest.commands,
|
||||
@@ -307,6 +313,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
};
|
||||
let currentPluginNodeHost = pluginNodeHost;
|
||||
let currentManifest = manifest;
|
||||
let gatewayConnection: { url: string; tlsFingerprint?: string } | undefined;
|
||||
let manager: NodeHostMcpManager | undefined;
|
||||
const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, {
|
||||
signal: mcpAbort.signal,
|
||||
@@ -348,6 +355,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
return {
|
||||
async invoke(frame) {
|
||||
const duplexCommand = duplexEnabled && isRegisteredNodeHostCommandDuplex(frame.command);
|
||||
const progressEnabled = duplexCommand || frame.command === NODE_DESKTOP_STREAM_COMMAND;
|
||||
const controller = new AbortController();
|
||||
// Every command must remain cancellable after dispatch; only duplex
|
||||
// commands own ordered input and its pre-spawn buffer.
|
||||
@@ -373,7 +381,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
// let its cleanup unregister the replacement invocation.
|
||||
activeInvokes.get(frame.id)?.controller.abort();
|
||||
activeInvokes.set(frame.id, active);
|
||||
const progress = duplexCommand
|
||||
const progress = progressEnabled
|
||||
? createNodeInvokeProgressWriter({
|
||||
client,
|
||||
frame,
|
||||
@@ -381,7 +389,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
onError: () => controller.abort(),
|
||||
})
|
||||
: undefined;
|
||||
progress?.startHeartbeats();
|
||||
if (duplexCommand) {
|
||||
progress?.startHeartbeats();
|
||||
}
|
||||
const pluginCommandIo: OpenClawPluginNodeHostCommandIo | undefined =
|
||||
input && progress
|
||||
? {
|
||||
@@ -399,6 +409,12 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
...(claudePath ? { claudePath } : {}),
|
||||
signal: controller.signal,
|
||||
...(pluginCommandIo ? { pluginCommandIo } : {}),
|
||||
...(gatewayConnection?.url ? { gatewayUrl: gatewayConnection.url } : {}),
|
||||
...(gatewayConnection?.tlsFingerprint
|
||||
? { gatewayTlsFingerprint: gatewayConnection.tlsFingerprint }
|
||||
: {}),
|
||||
...(config.desktop?.host ? { desktopHostConfig: config.desktop.host } : {}),
|
||||
...(progress ? { emitProgress: (text) => progress.write(text) } : {}),
|
||||
installedAppsSharingEnabled,
|
||||
installedAppsPlatform: platform,
|
||||
pluginCommandContext,
|
||||
@@ -426,6 +442,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
}
|
||||
activeInvokes.clear();
|
||||
},
|
||||
updateGatewayConnection(connection) {
|
||||
gatewayConnection = connection;
|
||||
},
|
||||
async close() {
|
||||
this.cancelAll();
|
||||
stopAvailabilityWatch();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const NODE_DESKTOP_STREAM_COMMAND = "desktop.stream";
|
||||
export const NODE_DESKTOP_ATTACH_PATH = "/node-desktop/attach";
|
||||
@@ -50,9 +50,13 @@ type PendingDesktopConnection = {
|
||||
type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult };
|
||||
|
||||
function desktopSourceForEnvironment(environment: Pick<EnvironmentSummary, "id">): DesktopSource {
|
||||
return environment.id === "gateway"
|
||||
? { kind: "host" }
|
||||
: { kind: "environment", environmentId: environment.id };
|
||||
if (environment.id === "gateway") {
|
||||
return { kind: "host" };
|
||||
}
|
||||
if (environment.id.startsWith("node:") && environment.id.length > "node:".length) {
|
||||
return { kind: "node", nodeId: environment.id.slice("node:".length) };
|
||||
}
|
||||
return { kind: "environment", environmentId: environment.id };
|
||||
}
|
||||
|
||||
/** `<openclaw-desktop-panel>` — dockable RFB access to Gateway desktop sources. */
|
||||
@@ -248,7 +252,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
this.controlTakeoverRecoveryUsed = options.takeoverRecovery === true;
|
||||
try {
|
||||
const observeCredentials =
|
||||
source.kind === "host" &&
|
||||
source.kind !== "environment" &&
|
||||
this.credentials?.password &&
|
||||
(this.credentialAuth === "vnc-password" ||
|
||||
(this.credentialAuth === "ard-account" && this.credentials.username))
|
||||
@@ -262,12 +266,18 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
if (operationId !== this.operationId) {
|
||||
return;
|
||||
}
|
||||
const credentials = observed.vncPassword
|
||||
? { password: observed.vncPassword }
|
||||
: observed.auth === "vnc-password"
|
||||
? this.credentials
|
||||
: undefined;
|
||||
if (observed.auth === "vnc-password" && !credentials?.password) {
|
||||
const credentials = observed.preauthenticated
|
||||
? undefined
|
||||
: observed.vncPassword
|
||||
? { password: observed.vncPassword }
|
||||
: observed.auth === "vnc-password"
|
||||
? this.credentials
|
||||
: undefined;
|
||||
if (
|
||||
observed.auth === "vnc-password" &&
|
||||
observed.preauthenticated !== true &&
|
||||
!credentials?.password
|
||||
) {
|
||||
this.credentialAuth = "vnc-password";
|
||||
this.pendingConnection = { environmentId, control, observed, operationId };
|
||||
this.state = "credentials";
|
||||
|
||||
@@ -270,6 +270,73 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists an observable node and connects with the node source arm", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["desktop.observe", "environments.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsList("local"),
|
||||
"environments.list": {
|
||||
environments: [
|
||||
{
|
||||
id: "node:paired-node",
|
||||
type: "node",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
capabilities: ["desktop.stream"],
|
||||
},
|
||||
{
|
||||
id: "node:plain-node",
|
||||
type: "node",
|
||||
status: "available",
|
||||
capabilities: ["screen.snapshot"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"desktop.observe": {
|
||||
sequence: [
|
||||
{
|
||||
__mockError: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "VNC password is required to observe this node",
|
||||
details: {
|
||||
code: "DESKTOP_CREDENTIALS_REQUIRED",
|
||||
auth: "vnc-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
transport: "rfb",
|
||||
wsPath: "/desktop/observe?token=node",
|
||||
expiresAtMs: 60_000,
|
||||
control: false,
|
||||
auth: "vnc-password",
|
||||
preauthenticated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const panel = await openDesktopPanel(page);
|
||||
await gateway.waitForRequest("environments.list");
|
||||
await panel.getByText("node:paired-node", { exact: true }).waitFor();
|
||||
expect(await panel.getByText("node:plain-node", { exact: true }).count()).toBe(0);
|
||||
await installDesktopClientFake(panel);
|
||||
|
||||
await panel.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await panel.getByLabel("VNC password", { exact: true }).fill("node-password");
|
||||
await panel.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect.poll(async () => await panel.getAttribute("data-connect-count")).toBe("1");
|
||||
expect(await panel.getAttribute("data-used-credentials")).toBe("false");
|
||||
const observeRequests = await gateway.getRequests("desktop.observe");
|
||||
expect(observeRequests.at(-1)?.params).toEqual({
|
||||
source: { kind: "node", nodeId: "paired-node" },
|
||||
control: false,
|
||||
credentials: { password: "node-password" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("launches advertised desktop apps and keeps observe controls working", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
|
||||
Reference in New Issue
Block a user