mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(gateway): view macOS Screen Sharing from the Desktop panel
Modern macOS only offers ARD account authentication for Screen Sharing, so the host desktop source refused every Mac. The Gateway now performs the ARD handshake itself against the loopback server and hands the browser a plain RFB 003.008 no-auth handshake, so the operator's macOS account password authenticates the desktop without ever reaching the browser, the observe result, a URL, or a log. - rfb-preauth: ARD (type 30) Diffie-Hellman with MD5-derived AES-128-ECB credentials, and VncAuth (type 2) bit-reversed DES, both under a single 10s negotiation deadline; Apple's RFB 003.889 maps to 3.8 - observe-bridge: runs pre-auth before splicing and starts the view-only filter at clientInit, since the browser handshake is consumed here; worker tokens keep the original version start phase - host-source: attaches ARD, requiring per-observation credentials that live only in the one-shot observer token and are dropped after use - doctor: offers an explicitly confirmed sudo launchctl repair when Screen Sharing is off, and prints the System Settings path otherwise Live-verified against this Mac's Screen Sharing: the DH exchange and credential framing are accepted and the server returns SecurityResult. The VncAuth DES vector is confirmed against OpenSSL independently.
This commit is contained in:
@@ -1 +1 @@
|
||||
{"contentHash":"e9f03948a5f27c201bbb9c985d5246766de68d058960c5a6f9894d5e7c4c5e5f","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
{"contentHash":"e228330edab112eacfa24979ad599be6cb051ffbf4bc1ac195b4c785e8582b82","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"3b555f468a1f5285f013d7259ebedd19269e93b3419fff98a46fb39cc7e33260","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"d7a5167cc11614d8a68f9a5d8b17c3e53c8689c8047d351618efaa33c376c754","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"fa8349747f4225a0ebc001b29ea83c2b4ccef461f4d33064f4a627ce5526187f","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"a3d3f96cf919ed8712cc17b8a2bd3789c091c461994ed6c5f4371e2e8c8aa279","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"a2f5849ea6b065cf9330bc23c8d5dde3dc2c887fd4d6ddbc923d3e9c6ced82fb","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
{"contentHash":"88f2302abb102413a5cd2edcdc571e2420ba56ce0f2278e4b4f43cf4680abb4e","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"161dd85ff2fb55f99cd244f0536fff9f98f61793456ff94816ea5b4d9ca3f111","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"735c8459a489a5e275924900feacdf70f4090e9d1ef61a60bcb2a55c72712d16","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"2f3449d689578379c2b49f52f1d1f0fd38a140f80352f964ba03780aff0b2a20","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"5fda9177a63cda38e65524938d2508e94ebc7ab0a903226153236a691bf72469","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"00c8d0e72e4bfcb3f265a92049853dcd8b005fdaca827ce25b72fac45117b8f1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
{"contentHash":"a7cb3d405ada2310b747b43dddd33ea35b76e0d5f9f6d002d9224910a6dd193f","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"eef0a7bd90bf5c7d351345a65d78e3d91751da9d61db276cad74366981b5266f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"1024c9cb87bfb6af8eb3d2c349f3357bdc8b8be1746d7b77a708ed468f8914bb","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"0d2511cd15d9760cd1d5f5e4ea6c2837b4614376705f7c0b0646ea451c56ed30","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"5b949452938af1bfea9a1d2660359936d86e33ceb8429455bcf1ed4d98112cea","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"bcd3c477aa058ce1114a212a6b2dbc1a4040691c68756045c1683eb51a689756","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"1d6787b9e0898a1aa0f9ca690fc1c9af17050fd60cf846c7b864cf3fe1d9d367","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"7a4049e6d740586be9789354dd4a00ea9eb687651a02f29705a0d982ad8f12a7","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
{"contentHash":"f379d6995a2ee77106b51703e1749aa351fad87e7a3d5696d3ee513f79f97fde","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"f2fdfad058605f29a610b906c1658a28bf5ef1ad3a99bd5781e841ed1d8f09dc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"ecd4857c6cab39ed452b4e7fa2ba613a68ed8f4587ae2cfccac073cd5a6e97f6","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"065757589694d6926e52731cf318bef7226583b7afbeddf16726bfdb567c5931","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"bc2670198acb21829e7a3a86c745c415debfd741cde3df6a3ea57fbe422708ce","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"ba13ff7c2e58e7491f27456bcd53ba5a580a4457f85d668853d5c672419dba14","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"e558cabf4ee7203b829dab7010ed0b899f2a57421ab922a3186fb86c3c55c971","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"9e184cbe1f9066c86e59ce18c1c96578d2e4f854c56ab751348fe4e75969af05","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
{"contentHash":"30bbb4b6dfb48348c2be4866e1d8d4fd52c3afe478d0d88641023afb996439c4","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"2ec29056ed15690afefbb066c21fcc131f59ed955136ac608a93e0ed7bab5aeb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"4cd594761004e8710a26ce22655e3fe79230be17cac189fe3c2bda550ffcc68d","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"50cfdf3fb473c5cfea47cf679cdd9ab65d5bba0a5a63bcdda0703fbd5573c343","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"d796b4381271aa5957da466432921010aa922ac354055b13bcfcfbb9dbc1ca59","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -9,12 +9,30 @@ import {
|
||||
describe("desktop protocol schemas", () => {
|
||||
it("accepts host and environment observe sources while rejecting unknown source kinds", () => {
|
||||
expect(validateDesktopObserveParams({ source: { kind: "host" }, control: true })).toBe(true);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "host" },
|
||||
credentials: { username: "operator", password: "secret" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "environment", environmentId: "worker:one" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateDesktopObserveParams({ source: { kind: "node", nodeId: "one" } })).toBe(false);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "environment", environmentId: "worker:one" },
|
||||
credentials: { password: "secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateDesktopObserveParams({
|
||||
source: { kind: "host" },
|
||||
credentials: { username: "", password: "secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(validateDesktopObserveParams({ source: { kind: "host", environmentId: "one" } })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -10,11 +10,24 @@ export const DesktopSourceSchema = Type.Union([
|
||||
closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }),
|
||||
]);
|
||||
|
||||
export const DesktopObserveParamsSchema = closedObject({
|
||||
source: DesktopSourceSchema,
|
||||
control: Type.Optional(Type.Boolean()),
|
||||
const DesktopObserveCredentialsSchema = closedObject({
|
||||
username: Type.Optional(NonEmptyString),
|
||||
password: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
export const DesktopObserveParamsSchema = Type.Union([
|
||||
closedObject({
|
||||
source: closedObject({ kind: Type.Literal("host") }),
|
||||
control: Type.Optional(Type.Boolean()),
|
||||
// Credentials exist only for this observe attempt and are never persisted or returned.
|
||||
credentials: Type.Optional(DesktopObserveCredentialsSchema),
|
||||
}),
|
||||
closedObject({
|
||||
source: closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }),
|
||||
control: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const DesktopObserveResultSchema = closedObject({
|
||||
transport: Type.String({ enum: ["rfb"] }),
|
||||
wsPath: NonEmptyString,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import * as hostSource from "../gateway/desktop/host-source.js";
|
||||
import { noteHostDesktopHealth } from "./doctor-host-desktop.js";
|
||||
|
||||
vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: vi.fn() }));
|
||||
@@ -9,9 +10,28 @@ const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(note).mockReset();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
||||
});
|
||||
|
||||
const unavailableInspection: hostSource.HostDesktopInspection = {
|
||||
status: { enabled: true, state: "unavailable", port: 5900 },
|
||||
detail:
|
||||
"gateway host desktop is unavailable at 127.0.0.1:5900. Enable System Settings -> General -> Sharing -> Screen Sharing.",
|
||||
unavailableReason: "not-listening",
|
||||
};
|
||||
|
||||
function commandResult(code: number) {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("host desktop doctor section", () => {
|
||||
it("reports the disabled Labs toggle", async () => {
|
||||
await noteHostDesktopHealth({});
|
||||
@@ -53,4 +73,77 @@ describe("host desktop doctor section", () => {
|
||||
"Host desktop",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the exact Screen Sharing launchctl repair only after interactive confirmation", async () => {
|
||||
vi.spyOn(hostSource, "inspectHostDesktop")
|
||||
.mockResolvedValueOnce(unavailableInspection)
|
||||
.mockResolvedValueOnce({
|
||||
status: { enabled: true, state: "attached", port: 5900, security: "ARD" },
|
||||
detail: "attached (127.0.0.1:5900, security: ARD)",
|
||||
});
|
||||
const confirmRuntimeRepair = vi.fn(async () => true);
|
||||
const runCommand = vi.fn(async (_argv: string[], _options: unknown) => commandResult(0));
|
||||
|
||||
await noteHostDesktopHealth(
|
||||
{ desktop: { host: { enabled: true } } },
|
||||
{
|
||||
platform: "darwin",
|
||||
prompter: { shouldRepair: true, confirmRuntimeRepair },
|
||||
runCommand,
|
||||
},
|
||||
);
|
||||
|
||||
expect(confirmRuntimeRepair).toHaveBeenCalledWith({
|
||||
message: "Enable macOS Screen Sharing now using sudo launchctl?",
|
||||
initialValue: false,
|
||||
requiresInteractiveConfirmation: true,
|
||||
});
|
||||
expect(runCommand.mock.calls.map(([argv]) => argv)).toEqual([
|
||||
["sudo", "launchctl", "enable", "system/com.apple.screensharing"],
|
||||
["sudo", "launchctl", "kickstart", "-k", "system/com.apple.screensharing"],
|
||||
]);
|
||||
expect(note).toHaveBeenCalledWith("attached (127.0.0.1:5900, security: ARD)", "Host desktop");
|
||||
});
|
||||
|
||||
it("prints the System Settings path when interactive repair is declined", async () => {
|
||||
vi.spyOn(hostSource, "inspectHostDesktop").mockResolvedValue(unavailableInspection);
|
||||
const runCommand = vi.fn();
|
||||
await noteHostDesktopHealth(
|
||||
{ desktop: { host: { enabled: true } } },
|
||||
{
|
||||
platform: "darwin",
|
||||
prompter: { shouldRepair: true, confirmRuntimeRepair: vi.fn(async () => false) },
|
||||
runCommand: runCommand as never,
|
||||
},
|
||||
);
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Enable Screen Sharing manually in System Settings → General → Sharing → Screen Sharing.",
|
||||
"Host desktop repair",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops after a failed sudo command and prints both manual repair paths", async () => {
|
||||
vi.spyOn(hostSource, "inspectHostDesktop").mockResolvedValue(unavailableInspection);
|
||||
const runCommand = vi.fn(async () => commandResult(1));
|
||||
await noteHostDesktopHealth(
|
||||
{ desktop: { host: { enabled: true } } },
|
||||
{
|
||||
platform: "darwin",
|
||||
prompter: { shouldRepair: true, confirmRuntimeRepair: vi.fn(async () => true) },
|
||||
runCommand,
|
||||
},
|
||||
);
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing",
|
||||
),
|
||||
"Host desktop repair",
|
||||
);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("System Settings → General → Sharing → Screen Sharing"),
|
||||
"Host desktop repair",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,13 @@ import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HealthFinding } from "../flows/health-checks.js";
|
||||
import { inspectHostDesktop } from "../gateway/desktop/host-source.js";
|
||||
import { runCommandWithTimeout } from "../process/exec-runner.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const SCREEN_SHARING_PORT = 5900;
|
||||
const SCREEN_SHARING_COMMAND =
|
||||
"sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing";
|
||||
const SCREEN_SHARING_SETTINGS = "System Settings → General → Sharing → Screen Sharing";
|
||||
|
||||
/** Collects the non-mutating host desktop diagnostic shared by doctor modes. */
|
||||
export async function collectHostDesktopHealthFindings(
|
||||
@@ -18,10 +25,64 @@ export async function collectHostDesktopHealthFindings(
|
||||
];
|
||||
}
|
||||
|
||||
/** Renders the host desktop probe as its own doctor section; no repair is attempted. */
|
||||
export async function noteHostDesktopHealth(cfg: OpenClawConfig): Promise<void> {
|
||||
const [finding] = await collectHostDesktopHealthFindings(cfg);
|
||||
if (finding) {
|
||||
note(finding.message, "Host desktop");
|
||||
/** Renders host desktop health and offers an explicitly confirmed macOS service repair. */
|
||||
export async function noteHostDesktopHealth(
|
||||
cfg: OpenClawConfig,
|
||||
deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
prompter?: Pick<DoctorPrompter, "shouldRepair" | "confirmRuntimeRepair">;
|
||||
runCommand?: typeof runCommandWithTimeout;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const inspection = await inspectHostDesktop({ config: cfg.desktop?.host, platform });
|
||||
const finding: HealthFinding = {
|
||||
checkId: "core/doctor/host-desktop",
|
||||
severity: inspection.status.state === "unavailable" ? "warning" : "info",
|
||||
message: inspection.detail,
|
||||
path: "desktop.host",
|
||||
};
|
||||
note(finding.message, "Host desktop");
|
||||
if (
|
||||
platform !== "darwin" ||
|
||||
cfg.desktop?.host?.enabled !== true ||
|
||||
inspection.status.port !== SCREEN_SHARING_PORT ||
|
||||
inspection.unavailableReason !== "not-listening"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
note(
|
||||
`Repair command: ${SCREEN_SHARING_COMMAND}\nManual path: ${SCREEN_SHARING_SETTINGS}`,
|
||||
"Host desktop repair",
|
||||
);
|
||||
if (!deps.prompter?.shouldRepair) {
|
||||
return;
|
||||
}
|
||||
const approved = await deps.prompter.confirmRuntimeRepair({
|
||||
message: "Enable macOS Screen Sharing now using sudo launchctl?",
|
||||
initialValue: false,
|
||||
requiresInteractiveConfirmation: true,
|
||||
});
|
||||
if (!approved) {
|
||||
note(`Enable Screen Sharing manually in ${SCREEN_SHARING_SETTINGS}.`, "Host desktop repair");
|
||||
return;
|
||||
}
|
||||
|
||||
const runCommand = deps.runCommand ?? runCommandWithTimeout;
|
||||
for (const argv of [
|
||||
["sudo", "launchctl", "enable", "system/com.apple.screensharing"],
|
||||
["sudo", "launchctl", "kickstart", "-k", "system/com.apple.screensharing"],
|
||||
]) {
|
||||
const result = await runCommand(argv, { timeoutMs: 120_000 });
|
||||
if (result.code !== 0) {
|
||||
note(
|
||||
`Screen Sharing repair failed. Run ${SCREEN_SHARING_COMMAND}, or enable it in ${SCREEN_SHARING_SETTINGS}.`,
|
||||
"Host desktop repair",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const repaired = await inspectHostDesktop({ config: cfg.desktop.host, platform });
|
||||
note(repaired.detail, "Host desktop");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export type DesktopHostConfig = {
|
||||
enabled: boolean;
|
||||
/** Loopback RFB port of an already-running VNC server (default: 5900). */
|
||||
port?: number;
|
||||
/** Absolute VNC password-file path; omit on macOS for future account/ARD auth support. */
|
||||
/** Absolute VNC password-file path; macOS ARD account credentials stay per-observation. */
|
||||
passwordFile?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Pr
|
||||
|
||||
export async function runHostDesktopHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteHostDesktopHealth } = await import("../commands/doctor-host-desktop.js");
|
||||
await noteHostDesktopHealth(ctx.cfg);
|
||||
await noteHostDesktopHealth(ctx.cfg, { prompter: ctx.prompter });
|
||||
}
|
||||
|
||||
export async function runStartupChannelMaintenanceHealth(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Platform-specific next steps for preparing a loopback-only host VNC server. */
|
||||
const HOST_DESKTOP_GUIDANCE = {
|
||||
darwin:
|
||||
"Enable System Settings -> General -> Sharing -> Screen Sharing, or run `sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing`. Built-in Screen Sharing uses ARD auth, which is not supported yet; use a VncAuth server for this milestone.",
|
||||
"Enable System Settings -> General -> Sharing -> Screen Sharing, or run `sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing`.",
|
||||
linux:
|
||||
"Install TigerVNC with `apt install tigervnc-standalone-server` and run it loopback-only on port 5900, or run `x11vnc -display :0 -localhost -rfbport 5900 -forever -passwdfile <file>`. gnome-remote-desktop uses unsupported VeNCrypt.",
|
||||
win32:
|
||||
|
||||
@@ -1,40 +1,122 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import { createHostDesktopService } from "./host-source.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()));
|
||||
});
|
||||
|
||||
function readSocketBytes(socket: net.Socket, length: number): Promise<Buffer> {
|
||||
return new Promise((resolve) => {
|
||||
let buffered = Buffer.alloc(0);
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
if (buffered.length >= length) {
|
||||
socket.off("data", onData);
|
||||
resolve(buffered);
|
||||
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();
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
});
|
||||
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> {
|
||||
const chunk = this.chunks.shift();
|
||||
return (
|
||||
chunk ??
|
||||
(await new Promise<Buffer>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway host desktop observe integration", () => {
|
||||
it("upgrades a host token, proxies bytes, and filters view-only input", async () => {
|
||||
const peers: net.Socket[] = [];
|
||||
it("pre-authenticates ARD, synthesizes None, and starts view-only filtering at ClientInit", async () => {
|
||||
const peers = new Set<net.Socket>();
|
||||
let connectionCount = 0;
|
||||
let resolveObserverScript!: () => void;
|
||||
let rejectObserverScript!: (error: Error) => void;
|
||||
const observerScript = new Promise<void>((resolve, reject) => {
|
||||
resolveObserverScript = resolve;
|
||||
rejectObserverScript = reject;
|
||||
});
|
||||
const rfbServer = net.createServer((socket) => {
|
||||
peers.push(socket);
|
||||
socket.write(Buffer.from("RFB 003.008\n", "ascii"));
|
||||
if (peers.length === 1) {
|
||||
socket.once("data", () => socket.write(Buffer.from([1, 2])));
|
||||
}
|
||||
peers.add(socket);
|
||||
socket.once("close", () => peers.delete(socket));
|
||||
connectionCount += 1;
|
||||
const connectionIndex = connectionCount;
|
||||
const reader = new SocketReader(socket);
|
||||
void (async () => {
|
||||
try {
|
||||
socket.write(Buffer.from("RFB 003.889\n", "ascii"));
|
||||
expect(await reader.readExactly(12)).toEqual(VERSION);
|
||||
socket.write(Buffer.from([4, 30, 33, 36, 35]));
|
||||
if (connectionIndex === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(await reader.readExactly(1)).toEqual(Buffer.from([30]));
|
||||
const keyLength = 16;
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt16BE(5, 0);
|
||||
header.writeUInt16BE(keyLength, 2);
|
||||
const modulus = Buffer.alloc(keyLength);
|
||||
modulus.writeUInt16BE(7919, keyLength - 2);
|
||||
const serverPublic = Buffer.alloc(keyLength);
|
||||
serverPublic.writeUInt16BE(6817, keyLength - 2);
|
||||
socket.write(Buffer.concat([header, modulus, serverPublic]));
|
||||
expect(await reader.readExactly(128 + keyLength)).toHaveLength(128 + keyLength);
|
||||
socket.write(Buffer.alloc(4));
|
||||
|
||||
// Browser version/security bytes were consumed by the Gateway. ClientInit is first.
|
||||
expect(await reader.readExactly(1)).toEqual(Buffer.from([1]));
|
||||
socket.write(Buffer.from("server-init", "ascii"));
|
||||
const framebufferRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]);
|
||||
expect(await reader.readExactly(framebufferRequest.length)).toEqual(framebufferRequest);
|
||||
resolveObserverScript();
|
||||
} catch (error) {
|
||||
rejectObserverScript(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
})();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rfbServer.once("error", reject);
|
||||
@@ -60,8 +142,11 @@ describe("gateway host desktop observe integration", () => {
|
||||
registry,
|
||||
});
|
||||
cleanups.push(async () => registry.stopAll());
|
||||
const observed = await service.observe(false);
|
||||
expect(observed.auth).toBe("vnc-password");
|
||||
const observed = await service.observe({
|
||||
control: false,
|
||||
credentials: { username: "operator", password: "account-password" },
|
||||
});
|
||||
expect(observed.auth).toBe("ard-account");
|
||||
expect(observed.vncPassword).toBeUndefined();
|
||||
|
||||
const httpServer = http.createServer();
|
||||
@@ -83,23 +168,23 @@ describe("gateway host desktop observe integration", () => {
|
||||
);
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${httpAddress.port}${observed.wsPath}`);
|
||||
const browser = new WebSocketReader(ws);
|
||||
cleanups.push(async () => ws.terminate());
|
||||
const banner = new Promise<Buffer>((resolve, reject) => {
|
||||
ws.once("message", (data) => resolve(Buffer.from(data as Buffer)));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
await expect(banner).resolves.toEqual(Buffer.from("RFB 003.008\n", "ascii"));
|
||||
await vi.waitFor(() => expect(peers).toHaveLength(2));
|
||||
const observerPeer = peers[1];
|
||||
if (!observerPeer) {
|
||||
throw new Error("expected observer RFB peer");
|
||||
}
|
||||
expect(await browser.next()).toEqual(VERSION);
|
||||
// Coalesce the synthetic handshake replies with exclusive ClientInit.
|
||||
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("server-init", "ascii"));
|
||||
|
||||
const handshake = Buffer.concat([Buffer.from("RFB 003.008\n", "ascii"), Buffer.from([1, 1])]);
|
||||
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]);
|
||||
const forwarded = readSocketBytes(observerPeer, handshake.length + framebufferRequest.length);
|
||||
ws.send(Buffer.concat([handshake, keyEvent, framebufferRequest]));
|
||||
await expect(forwarded).resolves.toEqual(Buffer.concat([handshake, framebufferRequest]));
|
||||
ws.send(Buffer.concat([keyEvent, framebufferRequest]));
|
||||
await expect(observerScript).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => expect(connectionCount).toBe(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export class HostDesktopCredentialsRequiredError extends Error {
|
||||
readonly auth = "ard-account" as const;
|
||||
readonly detailCode = "DESKTOP_CREDENTIALS_REQUIRED" as const;
|
||||
|
||||
constructor() {
|
||||
super("macOS account credentials are required to observe Screen Sharing");
|
||||
this.name = "HostDesktopCredentialsRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isHostDesktopCredentialsRequiredError(
|
||||
error: unknown,
|
||||
): error is HostDesktopCredentialsRequiredError {
|
||||
return error instanceof HostDesktopCredentialsRequiredError;
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import { createHostDesktopSource } from "./host-source.js";
|
||||
import {
|
||||
createHostDesktopService,
|
||||
createHostDesktopSource,
|
||||
inspectHostDesktop,
|
||||
} from "./host-source.js";
|
||||
import { createDesktopSessionRegistry } from "./session-registry.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
@@ -81,6 +86,7 @@ describe("gateway host desktop source", () => {
|
||||
});
|
||||
await expect(source.acquire()).resolves.toEqual({
|
||||
attachment: { kind: "tcp", host: "127.0.0.1", port },
|
||||
auth: "vnc-password",
|
||||
vncPassword: password,
|
||||
});
|
||||
expect(isSecretValueRegisteredForRedaction(password)).toBe(true);
|
||||
@@ -91,18 +97,55 @@ describe("gateway host desktop source", () => {
|
||||
const source = createHostDesktopSource({ config: { enabled: true, port } });
|
||||
await expect(source.acquire()).resolves.toEqual({
|
||||
attachment: { kind: "tcp", host: "127.0.0.1", port },
|
||||
auth: "vnc-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses ARD account auth with the supported alternative", async () => {
|
||||
it("attaches ARD and keeps account credentials only in the observer token", async () => {
|
||||
const port = await listenRfb({ banner: "RFB 003.889\n", securityTypes: [30] });
|
||||
const source = createHostDesktopSource({
|
||||
config: { enabled: true, port },
|
||||
platform: "darwin",
|
||||
});
|
||||
await expect(source.acquire()).rejects.toThrow(
|
||||
"macOS Screen Sharing uses ARD account authentication, which is not supported yet; configure a VncAuth server and desktop.host.passwordFile, then restart the gateway",
|
||||
await expect(source.acquire()).resolves.toEqual({
|
||||
attachment: { kind: "tcp", host: "127.0.0.1", port },
|
||||
auth: "ard-account",
|
||||
});
|
||||
|
||||
const registry = createDesktopSessionRegistry();
|
||||
const service = createHostDesktopService({
|
||||
config: { enabled: true, port },
|
||||
platform: "darwin",
|
||||
registry,
|
||||
});
|
||||
cleanups.push(async () => registry.stopAll());
|
||||
await expect(service.observe({ control: false })).rejects.toThrow(
|
||||
"macOS account credentials are required",
|
||||
);
|
||||
const password = "mac-account-password";
|
||||
const observed = await service.observe({
|
||||
control: false,
|
||||
credentials: { username: "operator", password },
|
||||
});
|
||||
expect(observed).toMatchObject({ auth: "ard-account", control: false });
|
||||
expect(observed).not.toHaveProperty("vncPassword");
|
||||
expect(observed.wsPath).toMatch(/^\/desktop\/observe\?token=[a-f0-9]{48}$/u);
|
||||
expect(observed.wsPath).not.toContain("operator");
|
||||
expect(observed.wsPath).not.toContain(password);
|
||||
expect(isSecretValueRegisteredForRedaction(password)).toBe(true);
|
||||
|
||||
await expect(
|
||||
inspectHostDesktop({ config: { enabled: true, port }, platform: "darwin" }),
|
||||
).resolves.toMatchObject({
|
||||
status: { state: "attached", security: "ARD" },
|
||||
detail: `attached (127.0.0.1:${port}, security: ARD)`,
|
||||
});
|
||||
});
|
||||
|
||||
it("still refuses VeNCrypt", async () => {
|
||||
const port = await listenRfb({ securityTypes: [19] });
|
||||
const source = createHostDesktopSource({ config: { enabled: true, port } });
|
||||
await expect(source.acquire()).rejects.toThrow("VeNCrypt is not supported");
|
||||
});
|
||||
|
||||
it("reports a non-VNC occupant and the port config next step", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DesktopHostConfig } from "../../config/types.desktop.js";
|
||||
import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import type { RfbAttachment } from "./attachment.js";
|
||||
import { getHostDesktopGuidance } from "./host-guidance.js";
|
||||
import { HostDesktopCredentialsRequiredError } from "./host-source-errors.js";
|
||||
import { mintDesktopObserverToken } from "./observe-bridge.js";
|
||||
import { classifyRfbSecurity, probeRfbServer, type RfbProbeResult } from "./rfb-probe.js";
|
||||
import type { DesktopSessionRegistry } from "./session-registry.js";
|
||||
@@ -12,6 +13,7 @@ const HOST_DESKTOP_PROBE_TIMEOUT_MS = 1_500;
|
||||
|
||||
export type HostDesktopAcquireResult = {
|
||||
attachment: RfbAttachment;
|
||||
auth: "vnc-password" | "ard-account";
|
||||
vncPassword?: string;
|
||||
};
|
||||
|
||||
@@ -25,6 +27,7 @@ export type HostDesktopStatus = {
|
||||
export type HostDesktopInspection = {
|
||||
status: HostDesktopStatus;
|
||||
detail: string;
|
||||
unavailableReason?: "not-listening" | "not-rfb" | "unsupported";
|
||||
};
|
||||
|
||||
function nonRfbError(port: number): string {
|
||||
@@ -72,17 +75,19 @@ export async function inspectHostDesktop(params: {
|
||||
return {
|
||||
status: { enabled: true, state: "unavailable", port },
|
||||
detail: unavailableError(port, platform),
|
||||
unavailableReason: "not-listening",
|
||||
};
|
||||
}
|
||||
if (probe.kind === "not-rfb") {
|
||||
return {
|
||||
status: { enabled: true, state: "unavailable", port },
|
||||
detail: nonRfbError(port),
|
||||
unavailableReason: "not-rfb",
|
||||
};
|
||||
}
|
||||
const security = securityLabel(probe);
|
||||
const auth = classifyRfbSecurity(probe.securityTypes);
|
||||
if (auth === "vnc-password") {
|
||||
if (auth === "vnc-password" || auth === "ard-account") {
|
||||
return {
|
||||
status: { enabled: true, state: "attached", port, security },
|
||||
detail: `attached (127.0.0.1:${port}, security: ${security})`,
|
||||
@@ -91,12 +96,11 @@ export async function inspectHostDesktop(params: {
|
||||
const detail =
|
||||
auth === "none"
|
||||
? `unavailable: unauthenticated VNC server at 127.0.0.1:${port}; require a password-protected VncAuth server, then retry`
|
||||
: auth === "ard-account"
|
||||
? "unavailable: macOS Screen Sharing uses ARD account authentication, which is not supported yet; configure a VncAuth server and desktop.host.passwordFile, then restart the gateway"
|
||||
: `unavailable: ${security} security is not supported; configure a VncAuth server and desktop.host.passwordFile, then retry`;
|
||||
: `unavailable: ${security} security is not supported; configure a VncAuth server and desktop.host.passwordFile, then retry`;
|
||||
return {
|
||||
status: { enabled: true, state: "unavailable", port, security },
|
||||
detail,
|
||||
unavailableReason: "unsupported",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,11 +130,6 @@ export function createHostDesktopSource(params: {
|
||||
`refusing unauthenticated VNC server on 127.0.0.1:${port}; require a password-protected VncAuth server, then retry`,
|
||||
);
|
||||
}
|
||||
if (security === "ard-account") {
|
||||
throw new Error(
|
||||
"macOS Screen Sharing uses ARD account authentication, which is not supported yet; configure a VncAuth server and desktop.host.passwordFile, then restart the gateway",
|
||||
);
|
||||
}
|
||||
if (security === "unsupported") {
|
||||
const name = probe.securityTypes.includes(19) ? "VeNCrypt" : "the offered VNC security";
|
||||
throw new Error(
|
||||
@@ -161,6 +160,7 @@ export function createHostDesktopSource(params: {
|
||||
}
|
||||
return {
|
||||
attachment: { kind: "tcp", host: "127.0.0.1", port },
|
||||
auth: security,
|
||||
...(vncPassword ? { vncPassword } : {}),
|
||||
};
|
||||
};
|
||||
@@ -169,12 +169,15 @@ export function createHostDesktopSource(params: {
|
||||
}
|
||||
|
||||
export type HostDesktopService = {
|
||||
observe(control: boolean): Promise<{
|
||||
observe(params: {
|
||||
control: boolean;
|
||||
credentials?: { username?: string; password?: string };
|
||||
}): Promise<{
|
||||
transport: "rfb";
|
||||
wsPath: string;
|
||||
expiresAtMs: number;
|
||||
control: boolean;
|
||||
auth: "vnc-password";
|
||||
auth: "vnc-password" | "ard-account";
|
||||
vncPassword?: string;
|
||||
}>;
|
||||
status(): Promise<HostDesktopStatus>;
|
||||
@@ -191,27 +194,47 @@ export function createHostDesktopService(params: {
|
||||
...(params.platform ? { platform: params.platform } : {}),
|
||||
});
|
||||
return {
|
||||
async observe(control) {
|
||||
async observe(observeParams) {
|
||||
const acquired = await params.registry.acquire({
|
||||
sourceKey: "host",
|
||||
ownerEpoch: 0,
|
||||
start: source.acquire,
|
||||
});
|
||||
const auth = acquired.auth;
|
||||
if (!auth) {
|
||||
throw new Error("gateway host desktop authentication state is unavailable; retry observe");
|
||||
}
|
||||
let preauth:
|
||||
| {
|
||||
auth: "ard-account";
|
||||
credentials: { username: string; password: string };
|
||||
}
|
||||
| undefined;
|
||||
if (auth === "ard-account") {
|
||||
const username = observeParams.credentials?.username?.trim() ?? "";
|
||||
const password = observeParams.credentials?.password ?? "";
|
||||
if (!username || !password) {
|
||||
throw new HostDesktopCredentialsRequiredError();
|
||||
}
|
||||
registerSecretValueForRedaction(password);
|
||||
preauth = { auth: "ard-account", credentials: { username, password } };
|
||||
}
|
||||
const minted = mintDesktopObserverToken({
|
||||
sourceKey: "host",
|
||||
ownerEpoch: 0,
|
||||
control,
|
||||
control: observeParams.control,
|
||||
attachment: acquired.attachment,
|
||||
...(preauth ? { preauth } : {}),
|
||||
});
|
||||
return {
|
||||
transport: "rfb",
|
||||
wsPath: `/desktop/observe?token=${minted.token}`,
|
||||
expiresAtMs: minted.expiresAtMs,
|
||||
control,
|
||||
// Host attach only reaches this point for VncAuth servers; every other
|
||||
// security type is refused in acquire(). ARD lands in a later milestone.
|
||||
auth: "vnc-password",
|
||||
...(acquired.vncPassword ? { vncPassword: acquired.vncPassword } : {}),
|
||||
control: observeParams.control,
|
||||
auth,
|
||||
...(auth === "vnc-password" && acquired.vncPassword
|
||||
? { vncPassword: acquired.vncPassword }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
async status() {
|
||||
|
||||
@@ -3,6 +3,13 @@ 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 {
|
||||
preauthenticateRfb,
|
||||
RfbPreauthBuffer,
|
||||
type RfbPreauthDescriptor,
|
||||
type RfbPreauthPeer,
|
||||
RfbPreauthTimeoutError,
|
||||
} from "./rfb-preauth.js";
|
||||
import { createRfbClientMessageFilter } from "./rfb-view-only-filter.js";
|
||||
import type { DesktopSessionRegistry } from "./session-registry.js";
|
||||
|
||||
@@ -18,7 +25,9 @@ type DesktopObserverTokenEntry = {
|
||||
ownerEpoch: number;
|
||||
control: boolean;
|
||||
attachment: RfbAttachment;
|
||||
preauth?: RfbPreauthDescriptor;
|
||||
expiresAt: number;
|
||||
expiryTimer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const observerTokens = new Map<string, DesktopObserverTokenEntry>();
|
||||
@@ -28,6 +37,7 @@ function pruneDesktopObserverTokens(nowMs: number): void {
|
||||
for (const [token, entry] of observerTokens) {
|
||||
if (entry.expiresAt <= nowMs) {
|
||||
observerTokens.delete(token);
|
||||
clearTimeout(entry.expiryTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,19 +47,28 @@ export function mintDesktopObserverToken(params: {
|
||||
ownerEpoch: number;
|
||||
control: boolean;
|
||||
attachment: RfbAttachment;
|
||||
preauth?: RfbPreauthDescriptor;
|
||||
nowMs?: number;
|
||||
}): { token: string; expiresAtMs: number } {
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
pruneDesktopObserverTokens(nowMs);
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const expiresAtMs = nowMs + TOKEN_TTL_MS;
|
||||
observerTokens.set(token, {
|
||||
const entry: DesktopObserverTokenEntry = {
|
||||
sourceKey: params.sourceKey,
|
||||
ownerEpoch: params.ownerEpoch,
|
||||
control: params.control,
|
||||
attachment: params.attachment,
|
||||
...(params.preauth ? { preauth: params.preauth } : {}),
|
||||
expiresAt: expiresAtMs,
|
||||
});
|
||||
expiryTimer: setTimeout(() => {
|
||||
if (observerTokens.get(token) === entry) {
|
||||
observerTokens.delete(token);
|
||||
}
|
||||
}, TOKEN_TTL_MS),
|
||||
};
|
||||
entry.expiryTimer.unref?.();
|
||||
observerTokens.set(token, entry);
|
||||
return { token, expiresAtMs };
|
||||
}
|
||||
|
||||
@@ -67,6 +86,7 @@ function consumeDesktopObserverToken(
|
||||
return undefined;
|
||||
}
|
||||
observerTokens.delete(normalized);
|
||||
clearTimeout(entry.expiryTimer);
|
||||
return entry.expiresAt > nowMs ? entry : undefined;
|
||||
}
|
||||
|
||||
@@ -85,6 +105,66 @@ function rawDataBuffer(data: RawData): Buffer {
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
class WebSocketPreauthPeer implements RfbPreauthPeer {
|
||||
private readonly reader = new RfbPreauthBuffer();
|
||||
private readonly onMessage = (data: RawData, isBinary: boolean) => {
|
||||
if (!isBinary) {
|
||||
this.reader.fail(new Error("RFB browser sent a non-binary handshake frame"));
|
||||
} else {
|
||||
this.reader.push(rawDataBuffer(data));
|
||||
}
|
||||
};
|
||||
private readonly onClose = () => {
|
||||
this.reader.fail(new Error("RFB browser closed during authentication negotiation"));
|
||||
};
|
||||
private readonly onError = () => {
|
||||
this.reader.fail(new Error("RFB browser failed during authentication negotiation"));
|
||||
};
|
||||
|
||||
constructor(private readonly ws: WebSocket) {
|
||||
ws.on("message", this.onMessage);
|
||||
ws.once("close", this.onClose);
|
||||
ws.once("error", this.onError);
|
||||
}
|
||||
|
||||
async readExactly(length: number, signal: AbortSignal): Promise<Buffer> {
|
||||
return await this.reader.readExactly(length, signal);
|
||||
}
|
||||
|
||||
async write(buffer: Buffer, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(
|
||||
signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("RFB authentication negotiation aborted"),
|
||||
);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.ws.send(buffer, { binary: true }, (error) => {
|
||||
cleanup();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
detach(): Buffer {
|
||||
this.ws.off("message", this.onMessage);
|
||||
this.ws.off("close", this.onClose);
|
||||
this.ws.off("error", this.onError);
|
||||
return this.reader.takeBuffered();
|
||||
}
|
||||
}
|
||||
|
||||
/** Upgrades one authenticated observer token into a raw bidirectional RFB stream. */
|
||||
export function handleDesktopObserveUpgrade(
|
||||
req: IncomingMessage,
|
||||
@@ -117,8 +197,8 @@ export function handleDesktopObserveUpgrade(
|
||||
return;
|
||||
}
|
||||
const desktopSocket = connectRfbAttachment(entry.attachment);
|
||||
const clientMessageFilter = entry.control ? undefined : createRfbClientMessageFilter();
|
||||
let closed = false;
|
||||
let negotiating = Boolean(entry.preauth);
|
||||
let resumeTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const closeBoth = (code: number, reason: string) => {
|
||||
@@ -135,47 +215,93 @@ export function handleDesktopObserveUpgrade(
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (!isBinary || closed) {
|
||||
return;
|
||||
const startSplice = (browserRemainder: Buffer = Buffer.alloc(0), preauthenticated = false) => {
|
||||
const clientMessageFilter = entry.control
|
||||
? undefined
|
||||
: createRfbClientMessageFilter({
|
||||
startPhase: preauthenticated ? "clientInit" : "version",
|
||||
});
|
||||
const forwardClientChunk = (chunk: Buffer) => {
|
||||
if (!clientMessageFilter) {
|
||||
desktopSocket.write(chunk);
|
||||
return;
|
||||
}
|
||||
const result = clientMessageFilter.filter(chunk);
|
||||
if ("error" in result) {
|
||||
closeBoth(1008, "invalid view-only RFB stream");
|
||||
return;
|
||||
}
|
||||
if (result.forward.length > 0) {
|
||||
desktopSocket.write(result.forward);
|
||||
}
|
||||
};
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (!isBinary || closed) {
|
||||
return;
|
||||
}
|
||||
forwardClientChunk(rawDataBuffer(data));
|
||||
});
|
||||
desktopSocket.on("data", (chunk) => {
|
||||
if (closed || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
ws.send(chunk, { binary: true });
|
||||
const bufferedAmount = () => deps.getBufferedAmount?.(ws) ?? ws.bufferedAmount;
|
||||
if (bufferedAmount() <= PAUSE_BUFFERED_BYTES || resumeTimer) {
|
||||
return;
|
||||
}
|
||||
desktopSocket.pause();
|
||||
resumeTimer = setInterval(() => {
|
||||
if (bufferedAmount() <= PAUSE_BUFFERED_BYTES) {
|
||||
clearInterval(resumeTimer);
|
||||
resumeTimer = undefined;
|
||||
desktopSocket.resume();
|
||||
}
|
||||
}, RESUME_CHECK_MS);
|
||||
resumeTimer.unref?.();
|
||||
});
|
||||
if (browserRemainder.length > 0) {
|
||||
forwardClientChunk(browserRemainder);
|
||||
}
|
||||
const chunk = rawDataBuffer(data);
|
||||
if (!clientMessageFilter) {
|
||||
desktopSocket.write(chunk);
|
||||
return;
|
||||
}
|
||||
const result = clientMessageFilter.filter(chunk);
|
||||
if ("error" in result) {
|
||||
closeBoth(1008, "invalid view-only RFB stream");
|
||||
return;
|
||||
}
|
||||
if (result.forward.length > 0) {
|
||||
desktopSocket.write(result.forward);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.once("close", () => closeBoth(1000, "desktop observer closed"));
|
||||
ws.once("error", () => closeBoth(1011, "desktop observer failed"));
|
||||
desktopSocket.on("data", (chunk) => {
|
||||
if (closed || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
ws.send(chunk, { binary: true });
|
||||
const bufferedAmount = () => deps.getBufferedAmount?.(ws) ?? ws.bufferedAmount;
|
||||
if (bufferedAmount() <= PAUSE_BUFFERED_BYTES || resumeTimer) {
|
||||
return;
|
||||
}
|
||||
desktopSocket.pause();
|
||||
resumeTimer = setInterval(() => {
|
||||
if (bufferedAmount() <= PAUSE_BUFFERED_BYTES) {
|
||||
clearInterval(resumeTimer);
|
||||
resumeTimer = undefined;
|
||||
desktopSocket.resume();
|
||||
}
|
||||
}, RESUME_CHECK_MS);
|
||||
resumeTimer.unref?.();
|
||||
});
|
||||
desktopSocket.once("close", () => closeBoth(1000, "desktop stream closed"));
|
||||
desktopSocket.once("error", () => closeBoth(1011, "desktop stream failed"));
|
||||
desktopSocket.once("error", () =>
|
||||
closeBoth(
|
||||
negotiating ? 1008 : 1011,
|
||||
negotiating ? "desktop authentication failed" : "desktop stream failed",
|
||||
),
|
||||
);
|
||||
|
||||
if (!entry.preauth) {
|
||||
startSplice();
|
||||
return;
|
||||
}
|
||||
|
||||
const preauth = entry.preauth;
|
||||
const browser = new WebSocketPreauthPeer(ws);
|
||||
void (async () => {
|
||||
try {
|
||||
await preauthenticateRfb({ server: desktopSocket, browser, preauth });
|
||||
const remainder = browser.detach();
|
||||
entry.preauth = undefined;
|
||||
negotiating = false;
|
||||
if (!closed) {
|
||||
startSplice(remainder, true);
|
||||
}
|
||||
} catch (error) {
|
||||
browser.detach();
|
||||
entry.preauth = undefined;
|
||||
closeBoth(
|
||||
1008,
|
||||
error instanceof RfbPreauthTimeoutError
|
||||
? "desktop authentication timed out"
|
||||
: `desktop ${preauth.auth === "ard-account" ? "ARD" : "VNC"} authentication failed`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { createDecipheriv, createHash } from "node:crypto";
|
||||
import { type Duplex, duplexPair } from "node:stream";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
preauthenticateRfb,
|
||||
type RfbPreauthDescriptor,
|
||||
type RfbPreauthPeer,
|
||||
} from "./rfb-preauth.js";
|
||||
|
||||
const VERSION_3_8 = Buffer.from("RFB 003.008\n", "ascii");
|
||||
|
||||
class ScriptedPeer implements RfbPreauthPeer {
|
||||
private buffered = Buffer.alloc(0);
|
||||
private failure: Error | undefined;
|
||||
private readonly waiters = new Set<() => void>();
|
||||
|
||||
constructor(readonly stream: Duplex) {
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
this.buffered = Buffer.concat([this.buffered, chunk]);
|
||||
this.wake();
|
||||
});
|
||||
stream.once("error", (error) => {
|
||||
this.failure = error;
|
||||
this.wake();
|
||||
});
|
||||
stream.once("close", () => {
|
||||
this.failure = new Error("scripted peer closed");
|
||||
this.wake();
|
||||
});
|
||||
}
|
||||
|
||||
private wake(): void {
|
||||
for (const waiter of this.waiters) {
|
||||
waiter();
|
||||
}
|
||||
this.waiters.clear();
|
||||
}
|
||||
|
||||
async readExactly(length: number, signal?: AbortSignal): Promise<Buffer> {
|
||||
while (this.buffered.length < length) {
|
||||
if (this.failure) {
|
||||
throw this.failure;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
this.waiters.delete(onWake);
|
||||
reject(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("scripted RFB negotiation aborted"),
|
||||
);
|
||||
};
|
||||
const onWake = () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
this.waiters.add(onWake);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
const value = this.buffered.subarray(0, length);
|
||||
this.buffered = this.buffered.subarray(length);
|
||||
return value;
|
||||
}
|
||||
|
||||
async write(buffer: Buffer): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.stream.write(buffer, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bigIntBuffer(value: bigint, length: number): Buffer {
|
||||
const hex = value.toString(16);
|
||||
const bytes = Buffer.from(hex.length % 2 === 0 ? hex : `0${hex}`, "hex");
|
||||
const result = Buffer.alloc(length);
|
||||
bytes.copy(result, length - bytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
function bufferBigInt(value: Buffer): bigint {
|
||||
return BigInt(`0x${value.toString("hex")}`);
|
||||
}
|
||||
|
||||
function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
|
||||
let result = 1n;
|
||||
let factor = base % modulus;
|
||||
let power = exponent;
|
||||
while (power > 0n) {
|
||||
if ((power & 1n) === 1n) {
|
||||
result = (result * factor) % modulus;
|
||||
}
|
||||
factor = (factor * factor) % modulus;
|
||||
power >>= 1n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function completeSyntheticBrowserHandshake(browser: ScriptedPeer): Promise<void> {
|
||||
expect(await browser.readExactly(12)).toEqual(VERSION_3_8);
|
||||
await browser.write(VERSION_3_8);
|
||||
expect(await browser.readExactly(2)).toEqual(Buffer.from([1, 1]));
|
||||
await browser.write(Buffer.from([1]));
|
||||
expect(await browser.readExactly(4)).toEqual(Buffer.alloc(4));
|
||||
}
|
||||
|
||||
async function runPreauth(params: {
|
||||
preauth: RfbPreauthDescriptor;
|
||||
serverScript: (server: ScriptedPeer) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const [gatewayServer, fakeServerStream] = duplexPair();
|
||||
const [gatewayBrowserStream, fakeBrowserStream] = duplexPair();
|
||||
const gatewayBrowser = new ScriptedPeer(gatewayBrowserStream);
|
||||
const fakeServer = new ScriptedPeer(fakeServerStream);
|
||||
const fakeBrowser = new ScriptedPeer(fakeBrowserStream);
|
||||
try {
|
||||
await Promise.all([
|
||||
preauthenticateRfb({
|
||||
server: gatewayServer,
|
||||
browser: gatewayBrowser,
|
||||
preauth: params.preauth,
|
||||
}),
|
||||
params.serverScript(fakeServer),
|
||||
completeSyntheticBrowserHandshake(fakeBrowser),
|
||||
]);
|
||||
} finally {
|
||||
gatewayServer.destroy();
|
||||
fakeServerStream.destroy();
|
||||
gatewayBrowserStream.destroy();
|
||||
fakeBrowserStream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeArdOffer(server: ScriptedPeer, keyLength: number): Promise<void> {
|
||||
await server.write(Buffer.from("RFB 003.889\n", "ascii"));
|
||||
expect(await server.readExactly(12)).toEqual(VERSION_3_8);
|
||||
await server.write(Buffer.from([4, 30, 33, 36, 35]));
|
||||
expect(await server.readExactly(1)).toEqual(Buffer.from([30]));
|
||||
const generator = 5n;
|
||||
const modulus = 7919n;
|
||||
const serverPrivate = 7n;
|
||||
const serverPublic = modPow(generator, serverPrivate, modulus);
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt16BE(Number(generator), 0);
|
||||
header.writeUInt16BE(keyLength, 2);
|
||||
await server.write(
|
||||
Buffer.concat([
|
||||
header,
|
||||
bigIntBuffer(modulus, keyLength),
|
||||
bigIntBuffer(serverPublic, keyLength),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("RFB server-side pre-authentication", () => {
|
||||
it.each([16, 32])(
|
||||
"negotiates ARD framing and encrypted credentials at %i bytes",
|
||||
async (keyLength) => {
|
||||
const username = "screen-user";
|
||||
const password = "screen-password";
|
||||
await runPreauth({
|
||||
preauth: { auth: "ard-account", credentials: { username, password } },
|
||||
serverScript: async (server) => {
|
||||
await writeArdOffer(server, keyLength);
|
||||
const response = await server.readExactly(128 + keyLength);
|
||||
expect(response).toHaveLength(128 + keyLength);
|
||||
|
||||
const modulus = 7919n;
|
||||
const serverPrivate = 7n;
|
||||
const clientPublic = bufferBigInt(response.subarray(128));
|
||||
const shared = modPow(clientPublic, serverPrivate, modulus);
|
||||
const key = createHash("md5").update(bigIntBuffer(shared, keyLength)).digest();
|
||||
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
||||
decipher.setAutoPadding(false);
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(response.subarray(0, 128)),
|
||||
decipher.final(),
|
||||
]);
|
||||
expect(plaintext.subarray(0, username.length).toString("utf8")).toBe(username);
|
||||
expect(plaintext[username.length]).toBe(0);
|
||||
expect(plaintext.subarray(64, 64 + password.length).toString("utf8")).toBe(password);
|
||||
expect(plaintext[64 + password.length]).toBe(0);
|
||||
await server.write(Buffer.alloc(4));
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 1025])("rejects malformed ARD key length %i", async (keyLength) => {
|
||||
const [gatewayServer, fakeServerStream] = duplexPair();
|
||||
const [gatewayBrowserStream, fakeBrowserStream] = duplexPair();
|
||||
const fakeServer = new ScriptedPeer(fakeServerStream);
|
||||
const preauth = preauthenticateRfb({
|
||||
server: gatewayServer,
|
||||
browser: new ScriptedPeer(gatewayBrowserStream),
|
||||
preauth: {
|
||||
auth: "ard-account",
|
||||
credentials: { username: "operator", password: "password" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await fakeServer.write(VERSION_3_8);
|
||||
expect(await fakeServer.readExactly(12)).toEqual(VERSION_3_8);
|
||||
await fakeServer.write(Buffer.from([1, 30]));
|
||||
expect(await fakeServer.readExactly(1)).toEqual(Buffer.from([30]));
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt16BE(5, 0);
|
||||
header.writeUInt16BE(keyLength, 2);
|
||||
await fakeServer.write(header);
|
||||
await expect(preauth).rejects.toThrow(`invalid ARD key length ${keyLength}`);
|
||||
} finally {
|
||||
gatewayServer.destroy();
|
||||
fakeServerStream.destroy();
|
||||
gatewayBrowserStream.destroy();
|
||||
fakeBrowserStream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects zero ARD Diffie-Hellman parameters", async () => {
|
||||
const [gatewayServer, fakeServerStream] = duplexPair();
|
||||
const [gatewayBrowserStream, fakeBrowserStream] = duplexPair();
|
||||
const fakeServer = new ScriptedPeer(fakeServerStream);
|
||||
const preauth = preauthenticateRfb({
|
||||
server: gatewayServer,
|
||||
browser: new ScriptedPeer(gatewayBrowserStream),
|
||||
preauth: {
|
||||
auth: "ard-account",
|
||||
credentials: { username: "operator", password: "password" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await fakeServer.write(VERSION_3_8);
|
||||
expect(await fakeServer.readExactly(12)).toEqual(VERSION_3_8);
|
||||
await fakeServer.write(Buffer.from([1, 30]));
|
||||
expect(await fakeServer.readExactly(1)).toEqual(Buffer.from([30]));
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt16BE(5, 0);
|
||||
header.writeUInt16BE(8, 2);
|
||||
await fakeServer.write(Buffer.concat([header, Buffer.alloc(16)]));
|
||||
await expect(preauth).rejects.toThrow("invalid ARD Diffie-Hellman parameters");
|
||||
} finally {
|
||||
gatewayServer.destroy();
|
||||
fakeServerStream.destroy();
|
||||
gatewayBrowserStream.destroy();
|
||||
fakeBrowserStream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces the ARD server SecurityResult reason", async () => {
|
||||
const reason = Buffer.from("account rejected", "utf8");
|
||||
const [gatewayServer, fakeServerStream] = duplexPair();
|
||||
const [gatewayBrowserStream, fakeBrowserStream] = duplexPair();
|
||||
const fakeServer = new ScriptedPeer(fakeServerStream);
|
||||
const preauth = preauthenticateRfb({
|
||||
server: gatewayServer,
|
||||
browser: new ScriptedPeer(gatewayBrowserStream),
|
||||
preauth: {
|
||||
auth: "ard-account",
|
||||
credentials: { username: "operator", password: "password" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeArdOffer(fakeServer, 16);
|
||||
await fakeServer.readExactly(144);
|
||||
const status = Buffer.alloc(8);
|
||||
status.writeUInt32BE(1, 0);
|
||||
status.writeUInt32BE(reason.length, 4);
|
||||
await fakeServer.write(Buffer.concat([status, reason]));
|
||||
await expect(preauth).rejects.toThrow("RFB authentication failed: account rejected");
|
||||
} finally {
|
||||
gatewayServer.destroy();
|
||||
fakeServerStream.destroy();
|
||||
gatewayBrowserStream.destroy();
|
||||
fakeBrowserStream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("matches the VncAuth bit-reversed DES challenge vector", async () => {
|
||||
const challenge = Buffer.from("0123456789abcdef", "ascii");
|
||||
await runPreauth({
|
||||
preauth: { auth: "vnc-password", credentials: { password: "password" } },
|
||||
serverScript: async (server) => {
|
||||
await server.write(VERSION_3_8);
|
||||
expect(await server.readExactly(12)).toEqual(VERSION_3_8);
|
||||
await server.write(Buffer.from([1, 2]));
|
||||
expect(await server.readExactly(1)).toEqual(Buffer.from([2]));
|
||||
await server.write(challenge);
|
||||
expect((await server.readExactly(16)).toString("hex")).toBe(
|
||||
"5645abeb5f1e6475e8feb11beb66ea19",
|
||||
);
|
||||
await server.write(Buffer.alloc(4));
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import type { Duplex } from "node:stream";
|
||||
|
||||
const RFB_VERSION_BYTES = 12;
|
||||
const RFB_3_3_VERSION = Buffer.from("RFB 003.003\n", "ascii");
|
||||
const RFB_3_8_VERSION = Buffer.from("RFB 003.008\n", "ascii");
|
||||
const RFB_SECURITY_NONE = 1;
|
||||
const RFB_SECURITY_VNC = 2;
|
||||
const RFB_SECURITY_ARD = 30;
|
||||
const MAX_ARD_KEY_BYTES = 1024;
|
||||
const MAX_REASON_BYTES = 64 * 1024;
|
||||
const DEFAULT_PREAUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type RfbPreauthDescriptor =
|
||||
| {
|
||||
auth: "ard-account";
|
||||
credentials: { username: string; password: string };
|
||||
}
|
||||
| {
|
||||
auth: "vnc-password";
|
||||
credentials: { password: string };
|
||||
};
|
||||
|
||||
export type RfbPreauthPeer = {
|
||||
readExactly(length: number, signal: AbortSignal): Promise<Buffer>;
|
||||
write(buffer: Buffer, signal: AbortSignal): Promise<void>;
|
||||
};
|
||||
|
||||
export class RfbPreauthTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("RFB authentication negotiation timed out");
|
||||
this.name = "RfbPreauthTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("RFB authentication negotiation aborted");
|
||||
}
|
||||
|
||||
/** Exact-byte queue shared by stream and WebSocket handshake adapters. */
|
||||
export class RfbPreauthBuffer {
|
||||
private buffered = Buffer.alloc(0);
|
||||
private failure: Error | undefined;
|
||||
private readonly waiters = new Set<() => void>();
|
||||
|
||||
push(chunk: Buffer): void {
|
||||
this.buffered = Buffer.concat([this.buffered, chunk]);
|
||||
this.wake();
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.failure = error;
|
||||
this.wake();
|
||||
}
|
||||
|
||||
private wake(): void {
|
||||
for (const waiter of this.waiters) {
|
||||
waiter();
|
||||
}
|
||||
this.waiters.clear();
|
||||
}
|
||||
|
||||
private async waitForData(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
throw abortReason(signal);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
this.waiters.delete(onWake);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onWake = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(abortReason(signal));
|
||||
};
|
||||
this.waiters.add(onWake);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async readExactly(length: number, signal: AbortSignal): Promise<Buffer> {
|
||||
while (this.buffered.length < length) {
|
||||
if (this.failure) {
|
||||
throw this.failure;
|
||||
}
|
||||
await this.waitForData(signal);
|
||||
}
|
||||
const value = this.buffered.subarray(0, length);
|
||||
this.buffered = this.buffered.subarray(length);
|
||||
return value;
|
||||
}
|
||||
|
||||
takeBuffered(): Buffer {
|
||||
const value = this.buffered;
|
||||
this.buffered = Buffer.alloc(0);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class StreamRfbPreauthPeer implements RfbPreauthPeer {
|
||||
private readonly reader = new RfbPreauthBuffer();
|
||||
|
||||
private readonly onData = (chunk: Buffer) => this.reader.push(chunk);
|
||||
private readonly onEnd = () => {
|
||||
this.reader.fail(new Error("RFB peer closed during authentication negotiation"));
|
||||
};
|
||||
private readonly onError = (error: Error) => {
|
||||
this.reader.fail(error);
|
||||
};
|
||||
|
||||
constructor(private readonly stream: Duplex) {
|
||||
stream.on("data", this.onData);
|
||||
stream.once("end", this.onEnd);
|
||||
stream.once("close", this.onEnd);
|
||||
stream.once("error", this.onError);
|
||||
}
|
||||
|
||||
async readExactly(length: number, signal: AbortSignal): Promise<Buffer> {
|
||||
return await this.reader.readExactly(length, signal);
|
||||
}
|
||||
|
||||
async write(buffer: Buffer, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
throw abortReason(signal);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(abortReason(signal));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.stream.write(buffer, (error) => {
|
||||
cleanup();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stream.off("data", this.onData);
|
||||
this.stream.off("end", this.onEnd);
|
||||
this.stream.off("close", this.onEnd);
|
||||
this.stream.off("error", this.onError);
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerVersion(banner: Buffer): { minor: number; reply: Buffer } {
|
||||
const match = /^RFB 003\.(\d{3})\n$/u.exec(banner.toString("ascii"));
|
||||
if (!match) {
|
||||
throw new Error(`unsupported RFB protocol version ${JSON.stringify(banner.toString("ascii"))}`);
|
||||
}
|
||||
const offeredMinor = Number.parseInt(match[1] ?? "", 10);
|
||||
if (offeredMinor === 889 || offeredMinor >= 7) {
|
||||
return { minor: 8, reply: RFB_3_8_VERSION };
|
||||
}
|
||||
return { minor: 3, reply: RFB_3_3_VERSION };
|
||||
}
|
||||
|
||||
async function readReason(peer: RfbPreauthPeer, signal: AbortSignal): Promise<string> {
|
||||
const length = (await peer.readExactly(4, signal)).readUInt32BE(0);
|
||||
if (length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (length > MAX_REASON_BYTES) {
|
||||
throw new Error("RFB failure reason is too large");
|
||||
}
|
||||
return (await peer.readExactly(length, signal)).toString("utf8");
|
||||
}
|
||||
|
||||
async function selectSecurityType(params: {
|
||||
peer: RfbPreauthPeer;
|
||||
protocolMinor: number;
|
||||
requiredType: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
if (params.protocolMinor < 7) {
|
||||
const selected = (await params.peer.readExactly(4, params.signal)).readUInt32BE(0);
|
||||
if (selected === 0) {
|
||||
const reason = await readReason(params.peer, params.signal);
|
||||
throw new Error(`RFB server rejected security negotiation${reason ? `: ${reason}` : ""}`);
|
||||
}
|
||||
if (selected !== params.requiredType) {
|
||||
throw new Error(`RFB server selected security type ${selected}, want ${params.requiredType}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const count = (await params.peer.readExactly(1, params.signal))[0] ?? 0;
|
||||
if (count === 0) {
|
||||
const reason = await readReason(params.peer, params.signal);
|
||||
throw new Error(`RFB server rejected security negotiation${reason ? `: ${reason}` : ""}`);
|
||||
}
|
||||
const offered = await params.peer.readExactly(count, params.signal);
|
||||
if (!offered.includes(params.requiredType)) {
|
||||
throw new Error(
|
||||
`RFB server did not offer required security type ${params.requiredType} (offered ${[
|
||||
...offered,
|
||||
].join(", ")})`,
|
||||
);
|
||||
}
|
||||
await params.peer.write(Buffer.from([params.requiredType]), params.signal);
|
||||
}
|
||||
|
||||
function bufferToBigInt(value: Buffer): bigint {
|
||||
return value.length === 0 ? 0n : BigInt(`0x${value.toString("hex")}`);
|
||||
}
|
||||
|
||||
function leftPadBigInt(value: bigint, length: number): Buffer {
|
||||
const hex = value.toString(16).padStart(2, "0");
|
||||
let bytes = Buffer.from(hex.length % 2 === 0 ? hex : `0${hex}`, "hex");
|
||||
if (bytes.length > length) {
|
||||
bytes = bytes.subarray(bytes.length - length);
|
||||
}
|
||||
const output = Buffer.alloc(length);
|
||||
bytes.copy(output, length - bytes.length);
|
||||
return output;
|
||||
}
|
||||
|
||||
function modularExponentiation(base: bigint, exponent: bigint, modulus: bigint): bigint {
|
||||
if (modulus <= 0n) {
|
||||
throw new Error("invalid ARD Diffie-Hellman modulus");
|
||||
}
|
||||
let result = 1n;
|
||||
let factor = base % modulus;
|
||||
let power = exponent;
|
||||
while (power > 0n) {
|
||||
if ((power & 1n) === 1n) {
|
||||
result = (result * factor) % modulus;
|
||||
}
|
||||
factor = (factor * factor) % modulus;
|
||||
power >>= 1n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildArdCredentialsBlock(username: string, password: string): Buffer {
|
||||
const block = randomBytes(128);
|
||||
const usernameBytes = Buffer.from(username, "utf8").subarray(0, 63);
|
||||
const passwordBytes = Buffer.from(password, "utf8").subarray(0, 63);
|
||||
usernameBytes.copy(block, 0);
|
||||
block[usernameBytes.length] = 0;
|
||||
passwordBytes.copy(block, 64);
|
||||
block[64 + passwordBytes.length] = 0;
|
||||
return block;
|
||||
}
|
||||
|
||||
function encryptAesEcb(key: Buffer, plaintext: Buffer): Buffer {
|
||||
const cipher = createCipheriv("aes-128-ecb", key, null);
|
||||
cipher.setAutoPadding(false);
|
||||
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
}
|
||||
|
||||
async function negotiateArdAuth(params: {
|
||||
peer: RfbPreauthPeer;
|
||||
credentials: { username: string; password: string };
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const header = await params.peer.readExactly(4, params.signal);
|
||||
const keyLength = header.readUInt16BE(2);
|
||||
if (keyLength < 1 || keyLength > MAX_ARD_KEY_BYTES) {
|
||||
throw new Error(`invalid ARD key length ${keyLength}`);
|
||||
}
|
||||
const dhParameters = await params.peer.readExactly(keyLength * 2, params.signal);
|
||||
const generator = bufferToBigInt(header.subarray(0, 2));
|
||||
const modulus = bufferToBigInt(dhParameters.subarray(0, keyLength));
|
||||
const serverPublic = bufferToBigInt(dhParameters.subarray(keyLength));
|
||||
if (generator === 0n || modulus === 0n || serverPublic === 0n) {
|
||||
throw new Error("invalid ARD Diffie-Hellman parameters");
|
||||
}
|
||||
|
||||
const privateKey = bufferToBigInt(randomBytes(keyLength));
|
||||
const clientPublic = modularExponentiation(generator, privateKey, modulus);
|
||||
const shared = modularExponentiation(serverPublic, privateKey, modulus);
|
||||
// MD5 and AES-ECB are mandated by ARD/RFB wire compatibility; they do not protect stored data.
|
||||
const key = createHash("md5").update(leftPadBigInt(shared, keyLength)).digest();
|
||||
const encryptedCredentials = encryptAesEcb(
|
||||
key,
|
||||
buildArdCredentialsBlock(params.credentials.username, params.credentials.password),
|
||||
);
|
||||
await params.peer.write(
|
||||
Buffer.concat([encryptedCredentials, leftPadBigInt(clientPublic, keyLength)]),
|
||||
params.signal,
|
||||
);
|
||||
}
|
||||
|
||||
function reverseByteBits(value: number): number {
|
||||
let input = value;
|
||||
let output = 0;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
output = (output << 1) | (input & 1);
|
||||
input >>= 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildVncAuthResponse(password: string, challenge: Buffer): Buffer {
|
||||
const key = Buffer.alloc(8);
|
||||
Buffer.from(password, "utf8").copy(key, 0, 0, 8);
|
||||
for (let index = 0; index < key.length; index += 1) {
|
||||
key[index] = reverseByteBits(key[index] ?? 0);
|
||||
}
|
||||
// RFB mandates single DES. EDE with K1=K2 is the same primitive on OpenSSL builds without des-ecb.
|
||||
const cipher = createCipheriv("des-ede", Buffer.concat([key, key]), null);
|
||||
cipher.setAutoPadding(false);
|
||||
return Buffer.concat([cipher.update(challenge), cipher.final()]);
|
||||
}
|
||||
|
||||
async function negotiateVncAuth(params: {
|
||||
peer: RfbPreauthPeer;
|
||||
password: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
if (!params.password) {
|
||||
throw new Error("VNC password is required");
|
||||
}
|
||||
const challenge = await params.peer.readExactly(16, params.signal);
|
||||
await params.peer.write(buildVncAuthResponse(params.password, challenge), params.signal);
|
||||
}
|
||||
|
||||
async function readSecurityResult(peer: RfbPreauthPeer, signal: AbortSignal): Promise<void> {
|
||||
const status = (await peer.readExactly(4, signal)).readUInt32BE(0);
|
||||
if (status === 0) {
|
||||
return;
|
||||
}
|
||||
let reason = "";
|
||||
try {
|
||||
reason = await readReason(peer, signal);
|
||||
} catch {
|
||||
// Older servers may close immediately after the status word.
|
||||
}
|
||||
throw new Error(
|
||||
reason
|
||||
? `RFB authentication failed: ${reason}`
|
||||
: `RFB authentication failed with status ${status}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function negotiateServer(params: {
|
||||
peer: RfbPreauthPeer;
|
||||
preauth: RfbPreauthDescriptor;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
params.preauth.auth === "ard-account" &&
|
||||
(!params.preauth.credentials.username || !params.preauth.credentials.password)
|
||||
) {
|
||||
throw new Error("ARD account username and password are required");
|
||||
}
|
||||
const banner = await params.peer.readExactly(RFB_VERSION_BYTES, params.signal);
|
||||
const version = parseServerVersion(banner);
|
||||
await params.peer.write(version.reply, params.signal);
|
||||
const requiredType = params.preauth.auth === "ard-account" ? RFB_SECURITY_ARD : RFB_SECURITY_VNC;
|
||||
await selectSecurityType({
|
||||
peer: params.peer,
|
||||
protocolMinor: version.minor,
|
||||
requiredType,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (params.preauth.auth === "ard-account") {
|
||||
await negotiateArdAuth({
|
||||
peer: params.peer,
|
||||
credentials: params.preauth.credentials,
|
||||
signal: params.signal,
|
||||
});
|
||||
} else {
|
||||
await negotiateVncAuth({
|
||||
peer: params.peer,
|
||||
password: params.preauth.credentials.password,
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
await readSecurityResult(params.peer, params.signal);
|
||||
}
|
||||
|
||||
async function synthesizeBrowserHandshake(
|
||||
browser: RfbPreauthPeer,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
await browser.write(RFB_3_8_VERSION, signal);
|
||||
const version = await browser.readExactly(RFB_VERSION_BYTES, signal);
|
||||
if (!version.equals(RFB_3_8_VERSION)) {
|
||||
throw new Error("RFB browser did not accept protocol version 3.8");
|
||||
}
|
||||
await browser.write(Buffer.from([1, RFB_SECURITY_NONE]), signal);
|
||||
const selected = await browser.readExactly(1, signal);
|
||||
if (selected[0] !== RFB_SECURITY_NONE) {
|
||||
throw new Error("RFB browser did not select no authentication");
|
||||
}
|
||||
await browser.write(Buffer.alloc(4), signal);
|
||||
}
|
||||
|
||||
/** Authenticates the Gateway to an RFB server, then exposes a synthetic None handshake. */
|
||||
export async function preauthenticateRfb(params: {
|
||||
server: Duplex;
|
||||
browser: RfbPreauthPeer;
|
||||
preauth: RfbPreauthDescriptor;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const server = new StreamRfbPreauthPeer(params.server);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new RfbPreauthTimeoutError()),
|
||||
params.timeoutMs ?? DEFAULT_PREAUTH_TIMEOUT_MS,
|
||||
);
|
||||
timeout.unref?.();
|
||||
try {
|
||||
await negotiateServer({ peer: server, preauth: params.preauth, signal: controller.signal });
|
||||
await synthesizeBrowserHandshake(params.browser, controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
server.dispose();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,16 @@ describe("RFB view-only client message filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts at ClientInit after server-side authentication without forwarding input", () => {
|
||||
const filter = createRfbClientMessageFilter({ startPhase: "clientInit" });
|
||||
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]);
|
||||
|
||||
expect(filter.filter(Buffer.concat([Buffer.from([0]), keyEvent, framebufferRequest]))).toEqual({
|
||||
forward: Buffer.concat([Buffer.from([1]), framebufferRequest]),
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on unsupported security types", () => {
|
||||
const filter = createRfbClientMessageFilter();
|
||||
expect(filter.filter(Buffer.concat([VERSION, Buffer.from([19])]))).toEqual({
|
||||
|
||||
@@ -14,8 +14,10 @@ type RfbClientMessageFilterResult =
|
||||
| { forward?: never; error: string };
|
||||
|
||||
/** Filters one view-only RFB client byte stream without trusting WebSocket frame boundaries. */
|
||||
export function createRfbClientMessageFilter() {
|
||||
let phase: RfbClientPhase = "version";
|
||||
export function createRfbClientMessageFilter(
|
||||
options: { startPhase?: "version" | "clientInit" } = {},
|
||||
) {
|
||||
let phase: RfbClientPhase = options.startPhase ?? "version";
|
||||
let pending = Buffer.alloc(0);
|
||||
let failure: string | undefined;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type DesktopSessionObserver = {
|
||||
|
||||
type DesktopSessionAcquireResult = {
|
||||
attachment: RfbAttachment;
|
||||
auth?: "vnc-password" | "ard-account";
|
||||
vncPassword?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import net from "node:net";
|
||||
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 { createDesktopSessionRegistry } from "../desktop/session-registry.js";
|
||||
import { environmentsHandlers } from "./environments.js";
|
||||
@@ -110,6 +111,51 @@ describe("desktop gateway methods", () => {
|
||||
expect(alias[1]).not.toHaveProperty("auth");
|
||||
});
|
||||
|
||||
it("reports ARD credentials as required and forwards an in-memory retry", async () => {
|
||||
const observe = vi.fn(
|
||||
async (params: { credentials?: { username?: string; password?: string } }) => {
|
||||
if (!params.credentials) {
|
||||
throw new HostDesktopCredentialsRequiredError();
|
||||
}
|
||||
return {
|
||||
transport: "rfb" as const,
|
||||
wsPath: "/desktop/observe?token=fixed",
|
||||
expiresAtMs: 42,
|
||||
control: false,
|
||||
auth: "ard-account" as const,
|
||||
};
|
||||
},
|
||||
);
|
||||
const context = {
|
||||
getRuntimeConfig: () => ({ desktop: { host: { enabled: true } } }),
|
||||
hostDesktopService: { observe },
|
||||
};
|
||||
const [firstOk, , firstError] = await invoke(
|
||||
"desktop.observe",
|
||||
{ source: { kind: "host" } },
|
||||
context,
|
||||
);
|
||||
expect(firstOk).toBe(false);
|
||||
expect(firstError).toMatchObject({
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
details: {
|
||||
code: "DESKTOP_CREDENTIALS_REQUIRED",
|
||||
auth: "ard-account",
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = { username: "operator", password: "account-password" };
|
||||
const [retryOk, result] = await invoke(
|
||||
"desktop.observe",
|
||||
{ source: { kind: "host" }, credentials },
|
||||
context,
|
||||
);
|
||||
expect(retryOk).toBe(true);
|
||||
expect(result).toMatchObject({ auth: "ard-account" });
|
||||
expect(result).not.toHaveProperty("vncPassword");
|
||||
expect(observe).toHaveBeenLastCalledWith({ control: false, credentials });
|
||||
});
|
||||
|
||||
it("rejects unknown desktop source kinds before dispatch", async () => {
|
||||
const [ok, , error] = await invoke(
|
||||
"desktop.observe",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { listNodePairing } from "../../infra/device-pairing-node.js";
|
||||
import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js";
|
||||
import type { NodeListNode } from "../../shared/node-list-types.js";
|
||||
import { isHostDesktopCredentialsRequiredError } from "../desktop/host-source-errors.js";
|
||||
import { createKnownNodeCatalog, listKnownNodes } from "../node-catalog.js";
|
||||
import type { WorkerEnvironmentServiceRecord } from "../worker-environments/service-contract.js";
|
||||
import type { WorkerEnvironmentState } from "../worker-environments/state.js";
|
||||
@@ -187,10 +188,28 @@ async function respondDesktopObserve(params: {
|
||||
try {
|
||||
params.respond(
|
||||
true,
|
||||
await params.context.hostDesktopService.observe(params.request.control ?? false),
|
||||
await params.context.hostDesktopService.observe({
|
||||
control: params.request.control ?? false,
|
||||
...("credentials" in params.request && params.request.credentials
|
||||
? { credentials: params.request.credentials }
|
||||
: {}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isHostDesktopCredentialsRequiredError(error)) {
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, error.message, {
|
||||
details: {
|
||||
code: error.detailCode,
|
||||
auth: error.auth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const DESKTOP_CREDENTIALS_REQUIRED_CODE = "DESKTOP_CREDENTIALS_REQUIRED";
|
||||
|
||||
/** Reads the host-observe retry contract without exposing credential material. */
|
||||
export function desktopCredentialRequirement(error: unknown): "ard-account" | null {
|
||||
if (!error || typeof error !== "object" || !("details" in error)) {
|
||||
return null;
|
||||
}
|
||||
const details = error.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return null;
|
||||
}
|
||||
return "code" in details &&
|
||||
details.code === DESKTOP_CREDENTIALS_REQUIRED_CODE &&
|
||||
"auth" in details &&
|
||||
details.auth === "ard-account"
|
||||
? "ard-account"
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const desktopPanelStyles = css`
|
||||
.bp--bottom {
|
||||
left: var(--shell-nav-width, 0);
|
||||
right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px));
|
||||
bottom: calc(var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px));
|
||||
}
|
||||
.bp--right {
|
||||
top: var(--shell-topbar-height, 0);
|
||||
right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px));
|
||||
bottom: var(--oc-terminal-reserve-bottom, 0px);
|
||||
}
|
||||
.bp-title {
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bp-icon.is-active {
|
||||
color: var(--accent, #ff5c5c);
|
||||
background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent);
|
||||
}
|
||||
.desktop-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.desktop-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border, #262b34);
|
||||
}
|
||||
.desktop-toolbar--connection {
|
||||
min-height: 42px;
|
||||
gap: 12px;
|
||||
}
|
||||
.desktop-toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.desktop-button {
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
background: transparent;
|
||||
color: var(--text, #d7dae0);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.desktop-button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
|
||||
}
|
||||
.desktop-button--primary {
|
||||
border-color: var(--accent, #ff5c5c);
|
||||
color: var(--accent, #ff5c5c);
|
||||
}
|
||||
.desktop-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.desktop-session {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.desktop-note {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border, #262b34);
|
||||
color: var(--muted, #8a919e);
|
||||
font-size: 12px;
|
||||
}
|
||||
.desktop-note--error {
|
||||
color: var(--danger, #ff6b6b);
|
||||
}
|
||||
.desktop-picker,
|
||||
.desktop-status {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.desktop-status {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted, #8a919e);
|
||||
}
|
||||
.desktop-credentials {
|
||||
display: flex;
|
||||
width: min(320px, 100%);
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.desktop-credentials__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 12px;
|
||||
}
|
||||
.desktop-credentials__input {
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 6px;
|
||||
padding: 7px 9px;
|
||||
background: var(--bg, #111318);
|
||||
color: var(--text, #d7dae0);
|
||||
font: inherit;
|
||||
}
|
||||
.desktop-environment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.desktop-environment__details {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.desktop-environment__id {
|
||||
overflow: hidden;
|
||||
color: var(--text, #d7dae0);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.desktop-environment__meta,
|
||||
.desktop-environment__sessions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--muted, #8a919e);
|
||||
font-size: 11px;
|
||||
}
|
||||
.desktop-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.desktop-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
`;
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
WorkerDesktopAppId,
|
||||
WorkerDesktopLaunchResult,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import { css, html, nothing, svg } from "lit";
|
||||
import { html, nothing, svg } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
} from "../panel-toggle-contract.ts";
|
||||
import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts";
|
||||
import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts";
|
||||
import { desktopCredentialRequirement } from "./desktop-panel-credentials.ts";
|
||||
import { desktopPanelLauncherStyles } from "./desktop-panel-launcher-styles.ts";
|
||||
import { desktopPanelStyles } from "./desktop-panel-styles.ts";
|
||||
|
||||
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
|
||||
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
|
||||
@@ -36,15 +38,16 @@ const panelLayout = createDockPanelLayout({
|
||||
defaultHeight: 420,
|
||||
defaultWidth: 560,
|
||||
});
|
||||
|
||||
type DesktopPanelState = "picker" | "credentials" | "connecting" | "connected" | "disconnected";
|
||||
type DesktopAppId = WorkerDesktopAppId;
|
||||
type DesktopCredentials = { username?: string; password?: string };
|
||||
type PendingDesktopConnection = {
|
||||
environmentId: string;
|
||||
observed: DesktopObserveResult;
|
||||
control: boolean;
|
||||
observed?: DesktopObserveResult;
|
||||
operationId: number;
|
||||
};
|
||||
type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult };
|
||||
|
||||
function desktopSourceForEnvironment(environment: Pick<EnvironmentSummary, "id">): DesktopSource {
|
||||
return environment.id === "gateway"
|
||||
@@ -76,6 +79,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
|
||||
private connection: DesktopConnectionHandle | null = null;
|
||||
private credentials: DesktopCredentials | undefined;
|
||||
private credentialAuth: "vnc-password" | "ard-account" | undefined;
|
||||
private pendingConnection: PendingDesktopConnection | null = null;
|
||||
private operationId = 0;
|
||||
private launchOperationId = 0;
|
||||
@@ -87,174 +91,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
});
|
||||
private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event);
|
||||
|
||||
static override styles = [
|
||||
dockPanelStyles,
|
||||
desktopPanelLauncherStyles,
|
||||
css`
|
||||
.bp--bottom {
|
||||
left: var(--shell-nav-width, 0);
|
||||
right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px));
|
||||
bottom: calc(
|
||||
var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px)
|
||||
);
|
||||
}
|
||||
.bp--right {
|
||||
top: var(--shell-topbar-height, 0);
|
||||
right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px));
|
||||
bottom: var(--oc-terminal-reserve-bottom, 0px);
|
||||
}
|
||||
.bp-title {
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bp-icon.is-active {
|
||||
color: var(--accent, #ff5c5c);
|
||||
background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent);
|
||||
}
|
||||
.desktop-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.desktop-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border, #262b34);
|
||||
}
|
||||
.desktop-toolbar--connection {
|
||||
min-height: 42px;
|
||||
gap: 12px;
|
||||
}
|
||||
.desktop-toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.desktop-button {
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
background: transparent;
|
||||
color: var(--text, #d7dae0);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.desktop-button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
|
||||
}
|
||||
.desktop-button--primary {
|
||||
border-color: var(--accent, #ff5c5c);
|
||||
color: var(--accent, #ff5c5c);
|
||||
}
|
||||
.desktop-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.desktop-session {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.desktop-note {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border, #262b34);
|
||||
color: var(--muted, #8a919e);
|
||||
font-size: 12px;
|
||||
}
|
||||
.desktop-note--error {
|
||||
color: var(--danger, #ff6b6b);
|
||||
}
|
||||
.desktop-picker,
|
||||
.desktop-status {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.desktop-status {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted, #8a919e);
|
||||
}
|
||||
.desktop-credentials {
|
||||
display: flex;
|
||||
width: min(320px, 100%);
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.desktop-credentials__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 12px;
|
||||
}
|
||||
.desktop-credentials__input {
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 6px;
|
||||
padding: 7px 9px;
|
||||
background: var(--bg, #111318);
|
||||
color: var(--text, #d7dae0);
|
||||
font: inherit;
|
||||
}
|
||||
.desktop-environment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.desktop-environment__details {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.desktop-environment__id {
|
||||
overflow: hidden;
|
||||
color: var(--text, #d7dae0);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.desktop-environment__meta,
|
||||
.desktop-environment__sessions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--muted, #8a919e);
|
||||
font-size: 11px;
|
||||
}
|
||||
.desktop-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.desktop-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
`,
|
||||
];
|
||||
static override styles = [dockPanelStyles, desktopPanelLauncherStyles, desktopPanelStyles];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -330,6 +167,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
this.environmentId = null;
|
||||
this.source = null;
|
||||
this.credentials = undefined;
|
||||
this.credentialAuth = undefined;
|
||||
this.desktopApps = [];
|
||||
this.controlling = false;
|
||||
this.disconnectedReason = null;
|
||||
@@ -386,6 +224,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
if (this.environmentId !== environmentId) {
|
||||
this.clearLaunchState();
|
||||
this.credentials = undefined;
|
||||
this.credentialAuth = undefined;
|
||||
this.desktopApps = [
|
||||
...(this.environments.find((environment) => environment.id === environmentId)?.worker
|
||||
?.desktopApps ?? []),
|
||||
@@ -411,6 +250,12 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
const observed = await client.request<DesktopObserveResult>("desktop.observe", {
|
||||
source,
|
||||
control,
|
||||
...(source.kind === "host" &&
|
||||
this.credentialAuth === "ard-account" &&
|
||||
this.credentials?.username &&
|
||||
this.credentials.password
|
||||
? { credentials: this.credentials }
|
||||
: {}),
|
||||
});
|
||||
if (operationId !== this.operationId) {
|
||||
return;
|
||||
@@ -421,18 +266,32 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
? this.credentials
|
||||
: undefined;
|
||||
if (observed.auth === "vnc-password" && !credentials?.password) {
|
||||
this.pendingConnection = { environmentId, observed, operationId };
|
||||
this.credentialAuth = "vnc-password";
|
||||
this.pendingConnection = { environmentId, control, observed, operationId };
|
||||
this.state = "credentials";
|
||||
return;
|
||||
}
|
||||
await this.connectObserved({ environmentId, observed, operationId }, credentials);
|
||||
if (observed.auth === "ard-account") {
|
||||
this.credentialAuth = "ard-account";
|
||||
}
|
||||
await this.connectObserved(
|
||||
{ environmentId, control, observed, operationId },
|
||||
observed.auth === "vnc-password" ? credentials : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const requiredAuth = desktopCredentialRequirement(error);
|
||||
if (requiredAuth && operationId === this.operationId) {
|
||||
this.credentialAuth = requiredAuth;
|
||||
this.pendingConnection = { environmentId, control, operationId };
|
||||
this.state = "credentials";
|
||||
return;
|
||||
}
|
||||
this.failConnection(operationId, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async connectObserved(
|
||||
pending: PendingDesktopConnection,
|
||||
pending: ObservedDesktopConnection,
|
||||
credentials?: DesktopCredentials,
|
||||
): Promise<void> {
|
||||
const client = this.client;
|
||||
@@ -498,19 +357,49 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
if (!pending || pending.operationId !== this.operationId) {
|
||||
return;
|
||||
}
|
||||
const password = new FormData(event.currentTarget as HTMLFormElement).get("password");
|
||||
const formData = new FormData(event.currentTarget as HTMLFormElement);
|
||||
const password = formData.get("password");
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
return;
|
||||
}
|
||||
const credentials = { password };
|
||||
const username = formData.get("username");
|
||||
if (
|
||||
this.credentialAuth === "ard-account" &&
|
||||
(typeof username !== "string" || username.trim().length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const credentials = {
|
||||
...(typeof username === "string" && username.trim() ? { username: username.trim() } : {}),
|
||||
password,
|
||||
};
|
||||
this.credentials = credentials;
|
||||
this.pendingConnection = null;
|
||||
void this.connectObserved(pending, credentials);
|
||||
if (pending.observed) {
|
||||
void this.connectObserved({ ...pending, observed: pending.observed }, credentials);
|
||||
} else {
|
||||
void this.connectEnvironment(pending.environmentId, pending.control);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDesktopDisconnect(environmentId: string, code?: number, reason?: string): void {
|
||||
this.connection = null;
|
||||
this.clearLaunchState();
|
||||
if (code === 1008 && this.credentialAuth === "ard-account") {
|
||||
this.credentials = this.credentials?.username
|
||||
? { username: this.credentials.username }
|
||||
: undefined;
|
||||
this.pendingConnection = {
|
||||
environmentId,
|
||||
control: this.controlling,
|
||||
operationId: this.operationId,
|
||||
};
|
||||
this.state = "credentials";
|
||||
this.errorText = t("desktop.errors.securityFailed", {
|
||||
reason: reason || t("desktop.unknownReason"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
code === 4000 &&
|
||||
reason === "control-taken" &&
|
||||
@@ -749,12 +638,29 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
}
|
||||
|
||||
private renderCredentials() {
|
||||
const ardAccount = this.credentialAuth === "ard-account";
|
||||
return html`
|
||||
<div class="desktop-status">
|
||||
<form class="desktop-credentials" @submit=${this.handleCredentialsSubmit}>
|
||||
<div>${t("desktop.passwordPrompt")}</div>
|
||||
<form
|
||||
class="desktop-credentials"
|
||||
@submit=${(event: SubmitEvent) => this.handleCredentialsSubmit(event)}
|
||||
>
|
||||
<div>${t(ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}</div>
|
||||
${ardAccount
|
||||
? html`<label class="desktop-credentials__label">
|
||||
${t("desktop.usernameLabel")}
|
||||
<input
|
||||
class="desktop-credentials__input"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
.value=${this.credentials?.username ?? ""}
|
||||
required
|
||||
/>
|
||||
</label>`
|
||||
: nothing}
|
||||
<label class="desktop-credentials__label">
|
||||
${t("desktop.passwordLabel")}
|
||||
${t(ardAccount ? "desktop.accountPasswordLabel" : "desktop.passwordLabel")}
|
||||
<input
|
||||
class="desktop-credentials__input"
|
||||
name="password"
|
||||
|
||||
@@ -48,7 +48,7 @@ async function installDesktopClientFake(panel: import("playwright").Locator) {
|
||||
(
|
||||
element as HTMLElement & {
|
||||
desktopClientFactory: () => {
|
||||
connect(options: { credentials?: { password?: string } }): Promise<{
|
||||
connect(options: { credentials?: { username?: string; password?: string } }): Promise<{
|
||||
disconnect(): void;
|
||||
}>;
|
||||
};
|
||||
@@ -167,6 +167,68 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries host observe with ARD credentials without passing them to noVNC", 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: "gateway", type: "local", status: "available", desktop: true }],
|
||||
},
|
||||
"desktop.observe": {
|
||||
sequence: [
|
||||
{
|
||||
__mockError: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "macOS account credentials are required to observe Screen Sharing",
|
||||
details: {
|
||||
code: "DESKTOP_CREDENTIALS_REQUIRED",
|
||||
auth: "ard-account",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
transport: "rfb",
|
||||
wsPath: "/desktop/observe?token=ard-host",
|
||||
expiresAtMs: 60_000,
|
||||
control: false,
|
||||
auth: "ard-account",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const panel = await openDesktopPanel(page);
|
||||
await gateway.waitForRequest("environments.list");
|
||||
await installDesktopClientFake(panel);
|
||||
await panel.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await panel
|
||||
.getByText("Enter a macOS account to authenticate Screen Sharing.", { exact: true })
|
||||
.waitFor();
|
||||
expect((await gateway.getRequests("desktop.observe"))[0]?.params).toEqual({
|
||||
source: { kind: "host" },
|
||||
control: false,
|
||||
});
|
||||
|
||||
await panel.getByLabel("macOS username", { exact: true }).fill("operator");
|
||||
await panel
|
||||
.getByLabel("macOS password", { exact: true })
|
||||
.fill("memory-only-account-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 requests = await gateway.getRequests("desktop.observe");
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[1]?.params).toEqual({
|
||||
source: { kind: "host" },
|
||||
control: false,
|
||||
credentials: { username: "operator", password: "memory-only-account-password" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("launches advertised desktop apps and keeps observe controls working", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
|
||||
@@ -1985,6 +1985,9 @@ export const en: TranslationMap = {
|
||||
reconnect: "Reconnect",
|
||||
passwordPrompt: "Enter the VNC password for this machine.",
|
||||
passwordLabel: "VNC password",
|
||||
accountPrompt: "Enter a macOS account to authenticate Screen Sharing.",
|
||||
usernameLabel: "macOS username",
|
||||
accountPasswordLabel: "macOS password",
|
||||
controlTaken: "Another operator took control",
|
||||
disconnected: "Desktop disconnected: {reason}",
|
||||
closeCode: "connection closed with code {code}",
|
||||
|
||||
Reference in New Issue
Block a user