mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-07 06:39:10 +08:00
[feat] add CrossDesk MCP with remote desktop panels
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
type Server,
|
||||
} from "node:http";
|
||||
import {
|
||||
createServer as createIPC,
|
||||
createConnection,
|
||||
type Server as IPCServer,
|
||||
} from "node:net";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, writeFile, unlink, chmod, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Failure, token, equal, failure, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
import { uiAction } from "./ui-actions.js";
|
||||
|
||||
const uiFile = fileURLToPath(new URL("./viewer-browser.html", import.meta.url));
|
||||
interface BrowserSession {
|
||||
csrf: string;
|
||||
views: Set<string>;
|
||||
expires: number;
|
||||
}
|
||||
export async function openURL(url: string): Promise<boolean> {
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!process.env.DISPLAY &&
|
||||
!process.env.WAYLAND_DISPLAY
|
||||
)
|
||||
return false;
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? "open"
|
||||
: process.platform === "win32"
|
||||
? "rundll32.exe"
|
||||
: "xdg-open";
|
||||
const args =
|
||||
process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
child.unref();
|
||||
resolve(true);
|
||||
}, 1500);
|
||||
child.once("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
export class BrowserPanel {
|
||||
private closed = false;
|
||||
private server?: Server;
|
||||
private starting?: Promise<void>;
|
||||
private ipc?: IPCServer;
|
||||
private ipcPath?: string;
|
||||
private descriptor?: string;
|
||||
private origin = "";
|
||||
private cookie = "crossdesk_" + token();
|
||||
private grants = new Map<string, { view: string; expires: number }>();
|
||||
private clients = new Map<string, BrowserSession>();
|
||||
private html = "";
|
||||
constructor(private sessions: Sessions) {}
|
||||
async connectDomains() {
|
||||
await this.start();
|
||||
// Native processes own ephemeral video ports, including after reconnect.
|
||||
// Restrict the wildcard to ports on the literal loopback address.
|
||||
return [this.origin, "ws://127.0.0.1:*"];
|
||||
}
|
||||
async video(view: View) {
|
||||
await this.start();
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
return this.sessions.openVideo(view, this.origin);
|
||||
}
|
||||
async open(view: View): Promise<Data> {
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
await this.start();
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
const opened = await openURL(this.bootstrapURL(view));
|
||||
return {
|
||||
...this.sessions.describe(view),
|
||||
browser_opened: opened,
|
||||
browser_url: this.origin,
|
||||
pairing_command: {
|
||||
command: "crossdesk-mcp",
|
||||
args: [
|
||||
"setup",
|
||||
"--open-panel",
|
||||
"--view",
|
||||
view.id,
|
||||
"--config-dir",
|
||||
this.sessions.store.directory,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
private bootstrapURL(view: View): string {
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
for (const [key, value] of this.grants)
|
||||
if (value.expires < Date.now()) this.grants.delete(key);
|
||||
if (this.grants.size >= 32) throw new Failure("VIEW_LIMIT");
|
||||
const grant = token();
|
||||
this.grants.set(grant, { view: view.id, expires: Date.now() + 60000 });
|
||||
return `${this.origin}/#bootstrap=${grant}`;
|
||||
}
|
||||
private async start() {
|
||||
this.starting ??= (async () => {
|
||||
this.html = await readFile(uiFile, "utf8");
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
this.server = createServer((req, res) => {
|
||||
void this.handle(req, res).catch(() => {
|
||||
if (!res.headersSent)
|
||||
this.respond(res, 500, { error: { code: "INTERNAL_ERROR" } });
|
||||
else res.end();
|
||||
});
|
||||
});
|
||||
// No upgrade handler or video bytes in the Node HTTP server.
|
||||
this.server.requestTimeout = 45000;
|
||||
this.server.headersTimeout = 10000;
|
||||
this.server.maxHeadersCount = 32;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.once("error", reject);
|
||||
this.server!.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
this.origin = `http://127.0.0.1:${(this.server.address() as import("node:net").AddressInfo).port}`;
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
await this.startIPC();
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
})().catch(async (error) => {
|
||||
await this.close(false);
|
||||
this.starting = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.starting;
|
||||
}
|
||||
private respond(res: ServerResponse, code: number, value: Data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
private client(req: IncomingMessage): BrowserSession | undefined {
|
||||
const value = req.headers.cookie
|
||||
?.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith(this.cookie + "="))
|
||||
?.slice(this.cookie.length + 1);
|
||||
const client = value ? this.clients.get(value) : undefined;
|
||||
return client && client.expires > Date.now() ? client : undefined;
|
||||
}
|
||||
private async body(req: IncomingMessage): Promise<Data> {
|
||||
if (req.headers["content-type"] !== "application/json")
|
||||
throw new Failure("CONTENT_TYPE_REQUIRED");
|
||||
let bytes = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 65536) throw new Failure("MESSAGE_TOO_LARGE");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!value || Array.isArray(value) || typeof value !== "object")
|
||||
throw new Error();
|
||||
return value;
|
||||
} catch {
|
||||
throw new Failure("INVALID_ARGUMENT");
|
||||
}
|
||||
}
|
||||
private async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
const authority = this.origin.slice("http://".length);
|
||||
if (
|
||||
req.headers.host !== authority ||
|
||||
(req.headers.origin && req.headers.origin !== this.origin)
|
||||
)
|
||||
return this.respond(res, 403, { error: { code: "ORIGIN_DENIED" } });
|
||||
if (req.url === "/" && req.method === "GET") {
|
||||
const nonce = token();
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data:; connect-src 'self' ws://127.0.0.1:*; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
||||
);
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(this.html.replaceAll("__CROSSDESK_NONCE__", nonce));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/context" && req.method === "GET") {
|
||||
const client = this.client(req);
|
||||
if (!client)
|
||||
return this.respond(res, 401, { error: { code: "PAIRING_REQUIRED" } });
|
||||
for (const id of client.views)
|
||||
if (!this.sessions.views.has(id)) client.views.delete(id);
|
||||
if (!client.views.size)
|
||||
return this.respond(res, 401, { error: { code: "PAIRING_REQUIRED" } });
|
||||
return this.respond(res, 200, {
|
||||
csrf: client.csrf,
|
||||
views: [...client.views].map((v) =>
|
||||
this.sessions.describe(this.sessions.view(v)),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (req.method !== "POST" || req.headers.origin !== this.origin)
|
||||
return this.respond(res, 403, { error: { code: "ORIGIN_DENIED" } });
|
||||
try {
|
||||
const body = await this.body(req);
|
||||
if (req.url === "/api/bootstrap") {
|
||||
const grant =
|
||||
typeof body.token === "string"
|
||||
? this.grants.get(body.token)
|
||||
: undefined;
|
||||
if (!grant || grant.expires < Date.now())
|
||||
throw new Failure("PAIRING_REQUIRED");
|
||||
this.grants.delete(body.token);
|
||||
for (const [id, client] of this.clients)
|
||||
if (client.expires < Date.now()) this.clients.delete(id);
|
||||
const existing = this.client(req);
|
||||
if (!existing && this.clients.size >= 32)
|
||||
throw new Failure("VIEW_LIMIT");
|
||||
const session = token(),
|
||||
client = existing ?? {
|
||||
csrf: token(),
|
||||
views: new Set<string>(),
|
||||
expires: 0,
|
||||
};
|
||||
client.views.add(grant.view);
|
||||
client.expires = Date.now() + 12 * 60 * 60 * 1000;
|
||||
for (const [key, previous] of this.clients)
|
||||
if (previous === client) this.clients.delete(key);
|
||||
this.clients.set(session, client);
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${this.cookie}=${session}; HttpOnly; SameSite=Strict; Path=/api`,
|
||||
);
|
||||
return this.respond(res, 200, {
|
||||
csrf: client.csrf,
|
||||
...this.sessions.describe(this.sessions.view(grant.view)),
|
||||
});
|
||||
}
|
||||
const client = this.client(req);
|
||||
if (
|
||||
!client ||
|
||||
typeof req.headers["x-crossdesk-csrf"] !== "string" ||
|
||||
!equal(client.csrf, req.headers["x-crossdesk-csrf"])
|
||||
)
|
||||
throw new Failure("PANEL_UNAUTHORIZED");
|
||||
if (typeof body.view_id !== "string" || !client.views.has(body.view_id))
|
||||
throw new Failure("VIEW_UNAUTHORIZED");
|
||||
const name = req.url?.match(
|
||||
/^\/api\/(ready|devices|status|connect|video_open|control)$/,
|
||||
)?.[1];
|
||||
if (!name) throw new Failure("UNKNOWN_ACTION");
|
||||
const { view_id, ...args } = body;
|
||||
const controller = new AbortController();
|
||||
res.once("close", () => {
|
||||
if (!res.writableEnded) controller.abort();
|
||||
});
|
||||
const result = name === "video_open" ? await this.video(this.sessions.view(view_id)) : await uiAction(
|
||||
this.sessions,
|
||||
this.sessions.view(view_id),
|
||||
name,
|
||||
args,
|
||||
controller.signal,
|
||||
);
|
||||
this.respond(res, 200, result);
|
||||
} catch (error) {
|
||||
this.respond(res, 400, { error: failure(error) });
|
||||
}
|
||||
}
|
||||
private async startIPC() {
|
||||
const secret = token();
|
||||
const suffix = token().slice(0, 20);
|
||||
this.ipcPath =
|
||||
process.platform === "win32"
|
||||
? `\\\\.\\pipe\\crossdesk-mcp-${suffix}`
|
||||
: join(tmpdir(), `cd-mcp-${suffix}.sock`);
|
||||
this.ipc = createIPC((socket) => {
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
let data = "",
|
||||
consumed = false;
|
||||
socket.on("error", () => {});
|
||||
socket.on("data", (part) => {
|
||||
data += part.toString("utf8");
|
||||
if (data.length > 2048) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (consumed || !data.includes("\n")) return;
|
||||
consumed = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const p = JSON.parse(data);
|
||||
if (
|
||||
typeof p.secret !== "string" ||
|
||||
!equal(p.secret, secret) ||
|
||||
typeof p.view_id !== "string"
|
||||
)
|
||||
throw new Failure("PAIRING_DENIED");
|
||||
const v = this.sessions.view(p.view_id);
|
||||
if (
|
||||
v.panel !== "browser" ||
|
||||
!["auto", "browser"].includes(this.sessions.policy)
|
||||
)
|
||||
throw new Failure("PANEL_POLICY_DENIED");
|
||||
// Only the authenticated local setup process receives this URL.
|
||||
// It uses its own desktop environment to open the browser.
|
||||
socket.end(
|
||||
JSON.stringify({ ok: true, url: this.bootstrapURL(v) }) + "\n",
|
||||
);
|
||||
} catch {
|
||||
socket.end('{"ok":false}\n');
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.ipc!.once("error", reject);
|
||||
this.ipc!.listen(this.ipcPath!, resolve);
|
||||
});
|
||||
if (process.platform !== "win32") await chmod(this.ipcPath, 0o600);
|
||||
this.descriptor = join(
|
||||
this.sessions.store.directory,
|
||||
`run-${process.pid}-${suffix}.json`,
|
||||
);
|
||||
await writeFile(
|
||||
this.descriptor,
|
||||
JSON.stringify({ pid: process.pid, endpoint: this.ipcPath, secret }),
|
||||
{ mode: 0o600, flag: "wx" },
|
||||
);
|
||||
}
|
||||
async close(final = true) {
|
||||
if (final) this.closed = true;
|
||||
await this.sessions.revokeVideo();
|
||||
this.grants.clear();
|
||||
this.clients.clear();
|
||||
this.server?.closeAllConnections();
|
||||
if (this.server?.listening) this.server.close();
|
||||
if (this.ipc?.listening) this.ipc.close();
|
||||
if (this.descriptor) await unlink(this.descriptor).catch(() => {});
|
||||
if (this.ipcPath && process.platform !== "win32")
|
||||
await unlink(this.ipcPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
export async function pairBrowser(
|
||||
directory: string,
|
||||
view: string,
|
||||
): Promise<string | undefined> {
|
||||
for (const file of await readdir(directory)) {
|
||||
if (!/^run-\d+-[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
|
||||
try {
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(join(directory, file), "utf8"),
|
||||
);
|
||||
const url = await new Promise<string | undefined>((resolve) => {
|
||||
const socket = createConnection(descriptor.endpoint);
|
||||
let data = "";
|
||||
socket.setTimeout(5000, () => {
|
||||
socket.destroy();
|
||||
resolve(undefined);
|
||||
});
|
||||
socket.once("error", () => resolve(undefined));
|
||||
socket.once("connect", () =>
|
||||
socket.write(
|
||||
JSON.stringify({ secret: descriptor.secret, view_id: view }) + "\n",
|
||||
),
|
||||
);
|
||||
socket.on("data", (part) => {
|
||||
data += part;
|
||||
if (data.length > 2048) socket.destroy();
|
||||
});
|
||||
socket.once("end", () => {
|
||||
try {
|
||||
const result = JSON.parse(data);
|
||||
resolve(
|
||||
result.ok &&
|
||||
typeof result.url === "string" &&
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/#bootstrap=[a-zA-Z0-9_-]+$/.test(
|
||||
result.url,
|
||||
)
|
||||
? result.url
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (url) return url;
|
||||
} catch {
|
||||
/* Another MCP instance may have exited. */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
export type Data = Record<string, any>;
|
||||
export const token = () => randomBytes(24).toString("base64url");
|
||||
export const equal = (a: string, b: string) => {
|
||||
const left = Buffer.from(a),
|
||||
right = Buffer.from(b);
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
};
|
||||
export class Failure extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
public sideEffect = false,
|
||||
public retryable = false,
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export function failure(error: unknown): Data {
|
||||
const e = error instanceof Failure ? error : new Failure("INTERNAL_ERROR");
|
||||
return {
|
||||
code: e.code,
|
||||
retryable: e.retryable,
|
||||
side_effect_possible: e.sideEffect,
|
||||
};
|
||||
}
|
||||
export function parse<T extends z.ZodType>(
|
||||
schema: T,
|
||||
value: unknown,
|
||||
): z.output<T> {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) throw new Failure("INVALID_ARGUMENT");
|
||||
return result.data;
|
||||
}
|
||||
export const id = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/);
|
||||
export const alias = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^[\p{L}\p{N} _.-]+$/u);
|
||||
export const mode = z.enum(["observe", "control"]);
|
||||
export const panel = z.enum(["auto", "apps", "browser", "off"]);
|
||||
export type Panel = z.infer<typeof panel>;
|
||||
export const videoOptions = z.strictObject({
|
||||
decode_path: z.enum(["browser", "minirtc"]).default("browser"),
|
||||
decoder: z.enum(["auto", "software"]).default("auto"),
|
||||
});
|
||||
export type VideoOptions = z.infer<typeof videoOptions>;
|
||||
export const host = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(253)
|
||||
.regex(/^[a-zA-Z0-9.:-]+$/);
|
||||
export const point = z.strictObject({
|
||||
x: z.number().finite().min(0).max(8192),
|
||||
y: z.number().finite().min(0).max(8192),
|
||||
});
|
||||
const button = z.enum(["left", "right", "middle"]).optional();
|
||||
const key = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16)
|
||||
.regex(/^[A-Z0-9_]+$/);
|
||||
export const action = z.discriminatedUnion("type", [
|
||||
point.extend({ type: z.literal("move") }),
|
||||
point.extend({ type: z.literal("click"), button }),
|
||||
point.extend({ type: z.literal("double_click"), button }),
|
||||
point.extend({
|
||||
type: z.literal("scroll"),
|
||||
direction: z.enum(["up", "down", "left", "right"]),
|
||||
ticks: z.int().min(1).max(10).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.literal("drag"),
|
||||
path: z.array(point).min(2).max(128),
|
||||
duration_ms: z.int().min(100).max(2000).optional(),
|
||||
}),
|
||||
z.strictObject({ type: z.literal("key_press"), key }),
|
||||
z.strictObject({
|
||||
type: z.literal("hotkey"),
|
||||
keys: z.array(key).min(2).max(6),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.literal("type_text"),
|
||||
text: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16384)
|
||||
.refine((s) => Buffer.byteLength(s) <= 16384 && !s.includes("\0")),
|
||||
}),
|
||||
]);
|
||||
export const screenshot = z.strictObject({
|
||||
session_id: id,
|
||||
region: z
|
||||
.strictObject({
|
||||
x: z.int().min(0),
|
||||
y: z.int().min(0),
|
||||
width: z.int().min(1).max(8192),
|
||||
height: z.int().min(1).max(8192),
|
||||
})
|
||||
.optional(),
|
||||
max_edge: z.int().min(64).max(2048).optional(),
|
||||
after_frame_id: z.int().min(0).optional(),
|
||||
generation: z.int().min(0).optional(),
|
||||
timeout_ms: z.int().min(0).max(5000).optional(),
|
||||
});
|
||||
export const uiConnect = z.strictObject({
|
||||
device_alias: alias.optional(),
|
||||
device_id: z
|
||||
.string()
|
||||
.regex(/^\d{9}$/)
|
||||
.optional(),
|
||||
label: alias.optional(),
|
||||
password: z
|
||||
.string()
|
||||
.max(256)
|
||||
.refine((s) => Buffer.byteLength(s) <= 256 && !/[\0\r\n]/.test(s))
|
||||
.optional(),
|
||||
mode: mode.default("observe"),
|
||||
display_index: z.int().min(0).max(63).default(0),
|
||||
server: host.optional(),
|
||||
port: z.int().min(1).max(65535).optional(),
|
||||
save_device: z.boolean().default(false),
|
||||
remember_password: z.boolean().default(false),
|
||||
persistent_authorization: z.boolean().default(false),
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseArgs } from "node:util";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import {
|
||||
serveStdio,
|
||||
StdioServerTransport,
|
||||
} from "@modelcontextprotocol/server/stdio";
|
||||
import { DeviceStore, type Device } from "./store.js";
|
||||
import { Sessions } from "./sessions.js";
|
||||
import { BrowserPanel, pairBrowser, openURL } from "./browser.js";
|
||||
import { createServer } from "./server.js";
|
||||
import {
|
||||
panel,
|
||||
parse,
|
||||
Failure,
|
||||
failure,
|
||||
uiConnect,
|
||||
videoOptions,
|
||||
type Panel,
|
||||
} from "./common.js";
|
||||
|
||||
async function setup(store: DeviceStore, view?: string) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
||||
throw new Failure("SETUP_REQUIRES_TERMINAL");
|
||||
if (view) {
|
||||
const url = await pairBrowser(store.directory, view);
|
||||
if (!url) throw new Failure("PANEL_NOT_FOUND");
|
||||
if (await openURL(url)) process.stdout.write("浏览器配对入口已打开。\n");
|
||||
else
|
||||
process.stdout.write(
|
||||
`请在本机浏览器打开下方一次性链接(60 秒有效,不要发送到聊天):\n${url}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let hidden = false;
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, done) {
|
||||
if (!hidden) process.stdout.write(chunk);
|
||||
done();
|
||||
},
|
||||
});
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output,
|
||||
terminal: true,
|
||||
historySize: 0,
|
||||
});
|
||||
const question = async (label: string) => (await rl.question(label)).trim();
|
||||
try {
|
||||
const config = await store.config();
|
||||
process.stdout.write(
|
||||
"CrossDesk MCP · 本地设备配置\n密码只保存到系统凭据库,不写入设备配置。\n",
|
||||
);
|
||||
const name = await question("设备别名:");
|
||||
const previous = config.devices.find((d) => d.alias === name);
|
||||
const device = previous?.device_id ?? (await question("9 位设备 ID:"));
|
||||
const server =
|
||||
previous?.server ??
|
||||
((await question(`服务器 [${config.server}]:`)) || config.server);
|
||||
const port =
|
||||
previous?.port ??
|
||||
Number((await question(`端口 [${config.port}]:`)) || config.port);
|
||||
const permission = await question(
|
||||
"允许未来自动连接?[n / observe / control]:",
|
||||
);
|
||||
const environment = await question(
|
||||
"使用现有密码环境变量(留空使用系统凭据库):",
|
||||
);
|
||||
let password: string | undefined;
|
||||
if (!environment) {
|
||||
hidden = true;
|
||||
process.stdout.write("访问密码(隐藏输入,可为空):");
|
||||
try {
|
||||
password = await rl.question("");
|
||||
} finally {
|
||||
hidden = false;
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
parse(uiConnect, {
|
||||
device_id: device,
|
||||
label: name,
|
||||
server,
|
||||
port,
|
||||
password,
|
||||
});
|
||||
const d: Device = {
|
||||
alias: name,
|
||||
device_id: device,
|
||||
server,
|
||||
port,
|
||||
...(permission === "observe" || permission === "control"
|
||||
? { authorization: permission }
|
||||
: {}),
|
||||
...(environment
|
||||
? { credential: "environment", environment_variable: environment }
|
||||
: {}),
|
||||
};
|
||||
await store.save(d, password);
|
||||
password = undefined;
|
||||
process.stdout.write(
|
||||
"已保存。Agent 可调用 crossdesk_list_devices;自动连接仍需有效授权和凭据。\n",
|
||||
);
|
||||
} finally {
|
||||
hidden = false;
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
strict: true,
|
||||
options: {
|
||||
stdio: { type: "boolean" },
|
||||
panel: { type: "string" },
|
||||
"video-path": { type: "string" },
|
||||
"video-decoder": { type: "string" },
|
||||
"config-dir": { type: "string" },
|
||||
"native-binary": { type: "string" },
|
||||
"open-panel": { type: "boolean" },
|
||||
view: { type: "string" },
|
||||
help: { type: "boolean" },
|
||||
version: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
if (values.version) {
|
||||
process.stdout.write("0.1.0\n");
|
||||
return;
|
||||
}
|
||||
if (values.help || !positionals.length) {
|
||||
process.stdout.write(
|
||||
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (positionals.length !== 1 || !["serve", "setup"].includes(positionals[0]!))
|
||||
throw new Failure("INVALID_COMMAND");
|
||||
const store = new DeviceStore(
|
||||
values["config-dir"] ?? process.env.CROSSDESK_MCP_CONFIG_DIR,
|
||||
);
|
||||
await store.initialize();
|
||||
if (positionals[0] === "setup") {
|
||||
if (values["open-panel"] && !values.view)
|
||||
throw new Failure("VIEW_REQUIRED");
|
||||
await setup(store, values["open-panel"] ? values.view : undefined);
|
||||
return;
|
||||
}
|
||||
if (!values.stdio) throw new Failure("STDIO_REQUIRED");
|
||||
const config = await store.config();
|
||||
const policy: Panel = parse(
|
||||
panel,
|
||||
values.panel ?? config.panel,
|
||||
);
|
||||
const sessions = new Sessions(
|
||||
store,
|
||||
policy,
|
||||
values["native-binary"] ?? process.env.CROSSDESK_NATIVE_BINARY,
|
||||
parse(videoOptions, {
|
||||
decode_path: values["video-path"] ?? config.video.decode_path,
|
||||
decoder: values["video-decoder"] ?? config.video.decoder,
|
||||
}),
|
||||
);
|
||||
const browser = new BrowserPanel(sessions);
|
||||
const handle = serveStdio(() => createServer(sessions, browser), {
|
||||
transport: new StdioServerTransport(),
|
||||
onerror: () => process.stderr.write("crossdesk-mcp: protocol error\n"),
|
||||
});
|
||||
let closing = false;
|
||||
const close = async () => {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
await browser.close();
|
||||
await sessions.close();
|
||||
await handle.close();
|
||||
};
|
||||
process.stdin.once("end", () => {
|
||||
void close();
|
||||
});
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const)
|
||||
process.once(signal, () => {
|
||||
void close().finally(() => {
|
||||
process.exitCode = 0;
|
||||
});
|
||||
});
|
||||
process.once("uncaughtException", () => {
|
||||
process.stderr.write("crossdesk-mcp: INTERNAL_ERROR\n");
|
||||
void close().finally(() => {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`crossdesk-mcp: ${failure(error).code}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { spawn, execFile, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Writable } from "node:stream";
|
||||
import { Failure, type Data, type VideoOptions } from "./common.js";
|
||||
|
||||
export const NATIVE_VERSION = "0.2.0";
|
||||
const run = promisify(execFile);
|
||||
const require = createRequire(import.meta.url);
|
||||
function nativeEnvironment(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const key of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"SYSTEMROOT",
|
||||
"WINDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
])
|
||||
if (process.env[key] !== undefined) env[key] = process.env[key];
|
||||
return env;
|
||||
}
|
||||
export async function nativeBinary(override?: string): Promise<string> {
|
||||
let binary: string;
|
||||
if (override) binary = resolve(override);
|
||||
else {
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
if (
|
||||
![
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64",
|
||||
"windows-x64",
|
||||
].includes(target)
|
||||
)
|
||||
throw new Failure("PLATFORM_UNSUPPORTED");
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!(process.report.getReport() as Data).header.glibcVersionRuntime
|
||||
)
|
||||
throw new Failure("GLIBC_REQUIRED");
|
||||
try {
|
||||
const manifest = require.resolve(
|
||||
`@chunkdash/crossdesk-cli-${target}/package.json`,
|
||||
);
|
||||
if (require(manifest).version !== NATIVE_VERSION)
|
||||
throw new Failure("NATIVE_VERSION_MISMATCH");
|
||||
binary = join(
|
||||
dirname(manifest),
|
||||
"bin",
|
||||
process.platform === "win32" ? "crossdesk-cli.exe" : "crossdesk-cli",
|
||||
);
|
||||
} catch (error) {
|
||||
throw error instanceof Failure
|
||||
? error
|
||||
: new Failure("NATIVE_PACKAGE_MISSING");
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { stdout } = await run(binary, ["doctor", "--json"], {
|
||||
timeout: 5000,
|
||||
maxBuffer: 65536,
|
||||
windowsHide: true,
|
||||
env: nativeEnvironment(),
|
||||
});
|
||||
const doctor = JSON.parse(stdout);
|
||||
if (doctor.automation_protocol !== "crossdesk-automation/1" ||
|
||||
doctor.automation_video_transport !== "loopback-websocket/1" ||
|
||||
!["h264-annexb/1", "nv12/1"].every((p) => doctor.automation_video_protocols?.includes(p)) ||
|
||||
!["auto", "software"].every((p) => doctor.automation_video_decoders?.includes(p)))
|
||||
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||
} catch (error) {
|
||||
throw error instanceof Failure ? error : new Failure("NATIVE_START_FAILED");
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: Data) => void;
|
||||
reject: (error: Failure) => void;
|
||||
clean: () => void;
|
||||
action: boolean;
|
||||
};
|
||||
export class NativeClient {
|
||||
private child: ChildProcess;
|
||||
private pending = new Map<number, Pending>();
|
||||
private sequence = 0;
|
||||
private buffer = "";
|
||||
private closed = false;
|
||||
private closing?: Promise<void>;
|
||||
constructor(binary: string, server: string, port: number, logs: string,
|
||||
readonly videoOptions: VideoOptions = { decode_path: "browser", decoder: "auto" }) {
|
||||
this.child = spawn(
|
||||
binary,
|
||||
[
|
||||
"agent",
|
||||
"--stdio",
|
||||
"--password-fd",
|
||||
"3",
|
||||
"--video-ws",
|
||||
"--server",
|
||||
server,
|
||||
"--port",
|
||||
String(port),
|
||||
"--log-dir",
|
||||
logs,
|
||||
],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "ignore", "pipe"],
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
env: nativeEnvironment(),
|
||||
},
|
||||
);
|
||||
this.child.stdout!.setEncoding("utf8");
|
||||
this.child.stdout!.on("data", (part: string) => this.receive(part));
|
||||
this.child.stdin!.on("error", () => this.fail());
|
||||
this.child.stdio[3]!.on("error", () => this.fail());
|
||||
this.child.once("error", () => this.fail());
|
||||
this.child.once("exit", () => this.fail());
|
||||
}
|
||||
async connect(
|
||||
device: string,
|
||||
password: string,
|
||||
mode: string,
|
||||
display: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Data> {
|
||||
if (Buffer.byteLength(password) > 256 || /[\0\r\n]/.test(password))
|
||||
throw new Failure("INVALID_CREDENTIAL");
|
||||
const hello = await this.request(
|
||||
"hello",
|
||||
{ protocol: "crossdesk-automation/1" },
|
||||
5000,
|
||||
signal,
|
||||
);
|
||||
if (hello.protocol !== "crossdesk-automation/1" ||
|
||||
hello.video_transport !== "loopback-websocket/1" ||
|
||||
!hello.video_protocols?.includes(this.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
|
||||
!hello.video_decoders?.includes(this.videoOptions.decoder))
|
||||
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||
const secret = Buffer.from(password + "\n");
|
||||
password = "";
|
||||
(this.child.stdio[3] as Writable).end(secret, () => secret.fill(0));
|
||||
return this.request("connect", { device, mode, display, ...this.videoOptions }, 7000, signal);
|
||||
}
|
||||
request(
|
||||
method: string,
|
||||
args: Data = {},
|
||||
timeout = 7000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Data> {
|
||||
if (this.closed)
|
||||
return Promise.reject(new Failure("SESSION_CLOSED", method === "action"));
|
||||
if (signal?.aborted) return Promise.reject(new Failure("CANCELLED"));
|
||||
if (this.pending.size >= 32 || this.child.stdin!.writableLength > 262144)
|
||||
return Promise.reject(new Failure("BUSY", false, true));
|
||||
const id = ++this.sequence;
|
||||
const line = JSON.stringify({ ...args, id, method }) + "\n";
|
||||
if (Buffer.byteLength(line) > 65536)
|
||||
return Promise.reject(new Failure("MESSAGE_TOO_LARGE"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = (code: string) => {
|
||||
const p = this.pending.get(id);
|
||||
if (!p) return;
|
||||
this.pending.delete(id);
|
||||
p.clean();
|
||||
// Cancellation is best effort; a timed-out action is never replayed.
|
||||
this.child.stdin?.write(
|
||||
JSON.stringify({
|
||||
id: ++this.sequence,
|
||||
method: "cancel",
|
||||
request_id: id,
|
||||
}) + "\n",
|
||||
);
|
||||
reject(new Failure(code, method === "action"));
|
||||
};
|
||||
const abort = () => cancel("CANCELLED");
|
||||
const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject,
|
||||
action: method === "action",
|
||||
clean: () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
},
|
||||
});
|
||||
this.child.stdin!.write(line);
|
||||
});
|
||||
}
|
||||
private receive(part: string) {
|
||||
this.buffer += part;
|
||||
if (Buffer.byteLength(this.buffer) > 8 * 1024 * 1024) {
|
||||
this.fail();
|
||||
this.child.kill();
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
const end = this.buffer.indexOf("\n");
|
||||
if (end < 0) break;
|
||||
const line = this.buffer.slice(0, end);
|
||||
this.buffer = this.buffer.slice(end + 1);
|
||||
try {
|
||||
if (Buffer.byteLength(line) > 4 * 1024 * 1024) throw new Error();
|
||||
const reply = JSON.parse(line);
|
||||
if (!Number.isSafeInteger(reply.id) || typeof reply.ok !== "boolean")
|
||||
throw new Error();
|
||||
const p = this.pending.get(reply.id);
|
||||
if (!p) continue;
|
||||
this.pending.delete(reply.id);
|
||||
p.clean();
|
||||
if (reply.ok) p.resolve(reply.result);
|
||||
else
|
||||
p.reject(
|
||||
new Failure(
|
||||
/^[A-Z_]+$/.test(reply.error?.code)
|
||||
? reply.error.code
|
||||
: "NATIVE_ERROR",
|
||||
!!reply.error?.side_effect_possible,
|
||||
!!reply.error?.retryable,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
this.fail();
|
||||
this.child.kill();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
private fail() {
|
||||
this.closed = true;
|
||||
for (const p of this.pending.values()) {
|
||||
p.clean();
|
||||
p.reject(new Failure("SESSION_CLOSED", p.action));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
close(): Promise<void> {
|
||||
if (this.closing) return this.closing;
|
||||
this.closing = (async () => {
|
||||
if (!this.closed)
|
||||
await this.request("disconnect", {}, 1000).catch(() => {});
|
||||
this.child.stdin?.end();
|
||||
if (this.child.exitCode === null && this.child.signalCode === null)
|
||||
await new Promise<void>((resolve) => {
|
||||
const kill = setTimeout(() => {
|
||||
this.child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 2000);
|
||||
this.child.once("exit", () => {
|
||||
clearTimeout(kill);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.fail();
|
||||
})();
|
||||
return this.closing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ClientCapabilities,
|
||||
Tool,
|
||||
ServerContext,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import {
|
||||
getUiCapability,
|
||||
registerAppResource,
|
||||
registerAppTool,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
import { BrowserPanel } from "./browser.js";
|
||||
import { uiAction, uiSchemas, type UIAction } from "./ui-actions.js";
|
||||
import {
|
||||
id,
|
||||
alias,
|
||||
mode,
|
||||
action,
|
||||
screenshot,
|
||||
Failure,
|
||||
failure,
|
||||
parse,
|
||||
token,
|
||||
type Data,
|
||||
} from "./common.js";
|
||||
|
||||
const resource = "ui://crossdesk/viewer.html";
|
||||
const uiNames: Record<UIAction, string> = {
|
||||
ready: "crossdesk_ui_ready",
|
||||
devices: "crossdesk_ui_list_devices",
|
||||
status: "crossdesk_ui_get_session",
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
control: "crossdesk_viewer_control",
|
||||
};
|
||||
function output(value: Data, meta?: Data): CallToolResult {
|
||||
const data = { ...value };
|
||||
const content: any[] = [];
|
||||
if (data.mime_type === "image/png" && typeof data.data === "string") {
|
||||
content.push({ type: "image", mimeType: data.mime_type, data: data.data });
|
||||
delete data.data;
|
||||
}
|
||||
if (data.observation?.data) {
|
||||
content.push({
|
||||
type: "image",
|
||||
mimeType: data.observation.mime_type,
|
||||
data: data.observation.data,
|
||||
});
|
||||
data.observation = { ...data.observation };
|
||||
delete data.observation.data;
|
||||
}
|
||||
content.unshift({ type: "text", text: JSON.stringify(data) });
|
||||
return { content, structuredContent: data, ...(meta ? { _meta: meta } : {}) };
|
||||
}
|
||||
export function createServer(
|
||||
sessions: Sessions,
|
||||
browser: BrowserPanel,
|
||||
): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: "crossdesk-mcp", version: "0.1.0" },
|
||||
{
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
instructions:
|
||||
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
|
||||
},
|
||||
);
|
||||
type Definition = {
|
||||
name: string;
|
||||
schema: z.ZodType;
|
||||
description: string;
|
||||
ui?: boolean;
|
||||
app?: boolean;
|
||||
readOnly?: boolean;
|
||||
run: (p: Data, ctx: ServerContext) => Promise<CallToolResult>;
|
||||
};
|
||||
const definitions = new Map<string, Definition>();
|
||||
const supported = (ctx: ServerContext) => {
|
||||
const envelope = ctx.mcpReq.envelope as Data | undefined;
|
||||
const caps = envelope
|
||||
? envelope["io.modelcontextprotocol/clientCapabilities"]
|
||||
: server.server.getClientCapabilities();
|
||||
return (
|
||||
getUiCapability((caps ?? {}) as ClientCapabilities)?.mimeTypes?.includes(
|
||||
RESOURCE_MIME_TYPE,
|
||||
) ?? false
|
||||
);
|
||||
};
|
||||
const apps = (ctx: ServerContext) =>
|
||||
["auto", "apps"].includes(sessions.policy) && supported(ctx);
|
||||
const viewResult = (v: View, result: Data) =>
|
||||
output(
|
||||
result,
|
||||
v.panel === "apps"
|
||||
? {
|
||||
"crossdesk/ui": {
|
||||
view_id: v.id,
|
||||
view_generation: v.generation,
|
||||
ui_token: v.secret,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const add = (
|
||||
name: string,
|
||||
schema: z.ZodType,
|
||||
description: string,
|
||||
run: Definition["run"],
|
||||
options: Partial<Definition> = {},
|
||||
) => {
|
||||
const d = { name, schema, description, run, ...options };
|
||||
definitions.set(name, d);
|
||||
const config = {
|
||||
description,
|
||||
inputSchema: schema,
|
||||
annotations: {
|
||||
readOnlyHint: !!d.readOnly,
|
||||
destructiveHint: d.name === "crossdesk_action",
|
||||
openWorldHint: true,
|
||||
},
|
||||
...(d.ui || d.app
|
||||
? {
|
||||
_meta: {
|
||||
ui: {
|
||||
...(d.ui ? { resourceUri: resource } : {}),
|
||||
visibility: d.app
|
||||
? ["app" as const]
|
||||
: ["model" as const, "app" as const],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (d.ui || d.app)
|
||||
registerAppTool(
|
||||
server,
|
||||
name,
|
||||
{ ...config, _meta: config._meta! },
|
||||
(p, ctx) => run(p as Data, ctx),
|
||||
);
|
||||
else server.registerTool(name, config, (p, ctx) => run(p as Data, ctx));
|
||||
};
|
||||
add(
|
||||
"crossdesk_open",
|
||||
z.strictObject({ device_alias: alias.optional() }),
|
||||
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. Returns immediately so the panel can render. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
|
||||
async (p, ctx) => {
|
||||
if (p.device_alias) await sessions.store.device(p.device_alias);
|
||||
const v = sessions.create(
|
||||
sessions.choose(supported(ctx)),
|
||||
p.device_alias,
|
||||
);
|
||||
return viewResult(
|
||||
v,
|
||||
v.panel === "browser" ? await browser.open(v) : sessions.describe(v),
|
||||
);
|
||||
},
|
||||
{ ui: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_open_browser",
|
||||
z.strictObject({ view_id: id.optional() }),
|
||||
"Explicitly open the local browser panel after the user requests browser fallback. Does not create another remote connection.",
|
||||
async (p) => {
|
||||
if (!["auto", "browser"].includes(sessions.policy))
|
||||
throw new Failure("PANEL_POLICY_DENIED");
|
||||
const v = p.view_id
|
||||
? sessions.view(p.view_id)
|
||||
: sessions.create("browser");
|
||||
if (v.panel !== "browser") {
|
||||
await sessions.revokeVideo(v);
|
||||
v.panel = "browser";
|
||||
v.generation++;
|
||||
v.secret = token();
|
||||
v.ready = false;
|
||||
v.created = Date.now();
|
||||
}
|
||||
return output(await browser.open(v));
|
||||
},
|
||||
);
|
||||
add(
|
||||
"crossdesk_list_devices",
|
||||
z.strictObject({}),
|
||||
"List the local saved device book and persistent authorization, without credentials. This is not online discovery.",
|
||||
async () => output({ devices: await sessions.store.list() }),
|
||||
{ readOnly: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_connect",
|
||||
z.strictObject({
|
||||
device_alias: alias,
|
||||
mode: mode.default("observe"),
|
||||
display_index: z.int().min(0).max(63).default(0),
|
||||
}),
|
||||
"Connect only a saved, previously authorized device with available credentials. AUTH_REQUIRED means the user must authorize through the panel or setup.",
|
||||
async (p, ctx) => {
|
||||
const selected = sessions.choose(supported(ctx));
|
||||
const result = await sessions.connectSaved(
|
||||
p.device_alias,
|
||||
p.mode,
|
||||
p.display_index,
|
||||
ctx.mcpReq.signal,
|
||||
);
|
||||
const v = sessions.create(selected, p.device_alias);
|
||||
v.session = result.session_id;
|
||||
return viewResult(v, {
|
||||
...result,
|
||||
...(selected === "browser"
|
||||
? await browser.open(v)
|
||||
: sessions.describe(v)),
|
||||
state: result.state,
|
||||
});
|
||||
},
|
||||
{ ui: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_get_session",
|
||||
z.strictObject({
|
||||
session_id: id.optional(),
|
||||
view_id: id.optional(),
|
||||
wait_ms: z.int().min(0).max(25000).optional(),
|
||||
}),
|
||||
"Get connection, display geometry, frame age and control state. Supply exactly one of session_id or view_id. With view_id, wait for connection (default 25000 ms; 0 returns immediately). On wait_timed_out, repeat while the remote task is pending. On ready, continue without asking the user to send a message. Waiting never connects or grants control. session_id returns immediately and requires wait_ms to be 0 or omitted.",
|
||||
async (p, ctx) => {
|
||||
if (!!p.session_id === !!p.view_id)
|
||||
throw new Failure("SELECT_ONE_SESSION");
|
||||
if (p.session_id && p.wait_ms) throw new Failure("INVALID_ARGUMENT");
|
||||
return output(p.view_id
|
||||
? await sessions.waitForConnection(p.view_id, p.wait_ms ?? 25000, ctx.mcpReq.signal)
|
||||
: await sessions.status(p.session_id, undefined, ctx.mcpReq.signal));
|
||||
},
|
||||
{ readOnly: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_screenshot",
|
||||
screenshot,
|
||||
"Capture the remote display as a PNG image and immutable observation metadata. Coordinates in subsequent actions use this returned image. after_frame_id requires generation.",
|
||||
async (p, ctx) => output(await sessions.screenshot(p, ctx.mcpReq.signal)),
|
||||
{ readOnly: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_action",
|
||||
z.strictObject({
|
||||
session_id: id,
|
||||
operation_id: id,
|
||||
observation_id: id,
|
||||
action,
|
||||
observe_after: z.boolean().default(true),
|
||||
}),
|
||||
"Submit one structured action against a recent observation. Reuse operation_id only to retrieve that same operation, never replay uncertain effects with a new ID. Human pause prevents control. A post-action screenshot is not an execution acknowledgement.",
|
||||
async (p, ctx) => output(await sessions.action(p, ctx.mcpReq.signal)),
|
||||
);
|
||||
add(
|
||||
"crossdesk_set_control",
|
||||
z.strictObject({ session_id: id, enabled: z.boolean() }),
|
||||
"Enable or disable control within the user-authorized scope. Cannot override human pause or upgrade an observe-only connection.",
|
||||
async (p) => output(await sessions.control(p.session_id, p.enabled)),
|
||||
);
|
||||
add(
|
||||
"crossdesk_select_display",
|
||||
z.strictObject({ session_id: id, display_index: z.int().min(0).max(63) }),
|
||||
"Select a remote display. Invalidates previous observations; capture a new screenshot before any further input.",
|
||||
async (p) => output(await sessions.display(p.session_id, p.display_index)),
|
||||
);
|
||||
add(
|
||||
"crossdesk_disconnect",
|
||||
z.strictObject({ session_id: id }),
|
||||
"Disconnect the remote session, release input state and stop associated previews.",
|
||||
async (p) => output(await sessions.disconnect(p.session_id)),
|
||||
);
|
||||
for (const [name, schema] of Object.entries(uiSchemas)) {
|
||||
add(
|
||||
uiNames[name as UIAction],
|
||||
schema.extend({
|
||||
view_id: id,
|
||||
view_generation: z.int().min(1),
|
||||
ui_token: id,
|
||||
}),
|
||||
`CrossDesk panel only: ${name}.`,
|
||||
async (p, ctx) => {
|
||||
if (!apps(ctx)) throw new Failure("PANEL_UNSUPPORTED");
|
||||
const { view_id, view_generation, ui_token, ...args } = p;
|
||||
const v = sessions.authenticate(view_id, view_generation, ui_token);
|
||||
if (v.panel !== "apps") throw new Failure("VIEW_UNAUTHORIZED");
|
||||
// Video grants stay in the authenticated app-only response.
|
||||
const result = name === "video_open" ? await browser.video(v) : await uiAction(
|
||||
sessions,
|
||||
v,
|
||||
name,
|
||||
args,
|
||||
ctx.mcpReq.signal,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: name === "video_open" ? "Video viewer connection" : JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
structuredContent: result,
|
||||
};
|
||||
},
|
||||
{ app: true },
|
||||
);
|
||||
}
|
||||
registerAppResource(
|
||||
server,
|
||||
"CrossDesk viewer",
|
||||
resource,
|
||||
{ mimeType: RESOURCE_MIME_TYPE },
|
||||
async (uri, ctx) => {
|
||||
if (!apps(ctx)) throw new Failure("PANEL_UNSUPPORTED");
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
text: await readFile(
|
||||
new URL("./viewer-apps.html", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
_meta: {
|
||||
ui: {
|
||||
prefersBorder: true,
|
||||
csp: { connectDomains: await browser.connectDomains(), resourceDomains: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
// Request-scoped projection avoids global tool mutations when modern clients
|
||||
// send concurrent calls with different capability envelopes.
|
||||
server.server.setRequestHandler("tools/list", async (_req, ctx) => ({
|
||||
tools: [...definitions.values()]
|
||||
.filter((d) => !d.app || apps(ctx))
|
||||
.map((d) => {
|
||||
const inputSchema = z.toJSONSchema(d.schema, {
|
||||
io: "input",
|
||||
}) as Tool["inputSchema"];
|
||||
if (d.name === "crossdesk_get_session")
|
||||
inputSchema.oneOf = [
|
||||
{ required: ["session_id"] },
|
||||
{ required: ["view_id"] },
|
||||
];
|
||||
return {
|
||||
name: d.name,
|
||||
description: d.description,
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: !!d.readOnly,
|
||||
destructiveHint: d.name === "crossdesk_action",
|
||||
openWorldHint: true,
|
||||
},
|
||||
...((d.ui || d.app) && apps(ctx)
|
||||
? {
|
||||
_meta: {
|
||||
ui: {
|
||||
...(d.ui ? { resourceUri: resource } : {}),
|
||||
visibility: d.app ? ["app"] : ["model", "app"],
|
||||
},
|
||||
...(d.ui ? { "ui/resourceUri": resource } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
server.server.setRequestHandler("tools/call", async (req, ctx) => {
|
||||
try {
|
||||
const d = definitions.get(req.params.name);
|
||||
if (!d || (d.app && !apps(ctx))) throw new Failure("UNKNOWN_TOOL");
|
||||
if (Buffer.byteLength(JSON.stringify(req.params.arguments ?? {})) > 65536)
|
||||
throw new Failure("MESSAGE_TOO_LARGE");
|
||||
const p = parse(d.schema, req.params.arguments ?? {}) as Data;
|
||||
return await d.run(p, ctx);
|
||||
} catch (error) {
|
||||
const value = { error: failure(error) };
|
||||
return { ...output(value), isError: true };
|
||||
}
|
||||
});
|
||||
server.server.setRequestHandler("resources/list", async (_req, ctx) => ({
|
||||
resources: apps(ctx)
|
||||
? [
|
||||
{
|
||||
name: "CrossDesk viewer",
|
||||
uri: resource,
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import {
|
||||
Failure,
|
||||
token,
|
||||
equal,
|
||||
parse,
|
||||
uiConnect,
|
||||
type Data,
|
||||
type Panel,
|
||||
type VideoOptions,
|
||||
} from "./common.js";
|
||||
import { DeviceStore, type Device } from "./store.js";
|
||||
import { nativeBinary, NativeClient } from "./native.js";
|
||||
|
||||
export interface View {
|
||||
id: string;
|
||||
generation: number;
|
||||
secret: string;
|
||||
panel: Exclude<Panel, "auto">;
|
||||
created: number;
|
||||
ready: boolean;
|
||||
lastSeen: number;
|
||||
alias?: string;
|
||||
session?: string;
|
||||
disconnected?: boolean;
|
||||
}
|
||||
interface Session {
|
||||
id: string;
|
||||
device: Device;
|
||||
native: NativeClient;
|
||||
status: Data;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
export class Sessions {
|
||||
readonly views = new Map<string, View>();
|
||||
private current?: Session;
|
||||
private connecting = false;
|
||||
private closing = false;
|
||||
private binary?: Promise<string>;
|
||||
constructor(
|
||||
readonly store: DeviceStore,
|
||||
readonly policy: Panel,
|
||||
private nativePath?: string,
|
||||
private videoOptions?: VideoOptions,
|
||||
) {}
|
||||
choose(apps: boolean): View["panel"] {
|
||||
if (this.policy === "apps" && !apps) throw new Failure("PANEL_UNSUPPORTED");
|
||||
return this.policy === "auto" ? (apps ? "apps" : "browser") : this.policy;
|
||||
}
|
||||
create(panel: View["panel"], alias?: string): View {
|
||||
for (const [id, view] of this.views)
|
||||
if (Date.now() - view.lastSeen > 30 * 60 * 1000) {
|
||||
void this.revokeVideo(view);
|
||||
this.views.delete(id);
|
||||
}
|
||||
if (this.views.size >= 32) throw new Failure("VIEW_LIMIT");
|
||||
const view: View = {
|
||||
id: token(),
|
||||
generation: 1,
|
||||
secret: token(),
|
||||
panel,
|
||||
created: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
ready: false,
|
||||
alias,
|
||||
session: this.current?.id,
|
||||
};
|
||||
this.views.set(view.id, view);
|
||||
return view;
|
||||
}
|
||||
view(id: string): View {
|
||||
const v = this.views.get(id);
|
||||
if (!v) throw new Failure("VIEW_NOT_FOUND");
|
||||
return v;
|
||||
}
|
||||
authenticate(id: string, generation: number, secret: string): View {
|
||||
const v = this.view(id);
|
||||
if (v.generation !== generation || !equal(v.secret, secret))
|
||||
throw new Failure("VIEW_UNAUTHORIZED");
|
||||
return v;
|
||||
}
|
||||
describe(v: View): Data {
|
||||
return {
|
||||
view_id: v.id,
|
||||
view_generation: v.generation,
|
||||
panel: v.panel,
|
||||
panel_status: v.ready
|
||||
? "ready"
|
||||
: Date.now() - v.created > 10000
|
||||
? "not_confirmed"
|
||||
: "waiting",
|
||||
session_id: v.session ?? null,
|
||||
state: v.session ? "session_available" : v.disconnected ? "closed" : "awaiting_user",
|
||||
device_alias: v.alias ?? null,
|
||||
next_step: v.session
|
||||
? "Call crossdesk_get_session with view_id."
|
||||
: v.disconnected
|
||||
? "The session was disconnected. Do not automatically reconnect."
|
||||
: v.panel === "off"
|
||||
? "Use setup to configure credentials and authorization, then crossdesk_connect with the saved device alias. Never send passwords in chat."
|
||||
: "The user selects and authorizes a device in the panel. To continue a pending remote task, immediately call crossdesk_get_session with this view_id and wait_ms: 25000; repeat on wait_timed_out. Do not ask the user to send a connection confirmation message.",
|
||||
};
|
||||
}
|
||||
private session(id: string): Session {
|
||||
if (!this.current || this.current.id !== id)
|
||||
throw new Failure("SESSION_NOT_FOUND");
|
||||
return this.current;
|
||||
}
|
||||
async status(sessionId?: string, viewId?: string, signal?: AbortSignal): Promise<Data> {
|
||||
const view = viewId ? this.view(viewId) : undefined;
|
||||
const id = sessionId ?? view?.session;
|
||||
if (!id && view) return this.describe(view);
|
||||
const s = this.session(id!);
|
||||
// A panel connection may still be performing the native hello handshake.
|
||||
// Reading native status here would race initialization and could close it.
|
||||
if (this.connecting) return {
|
||||
...(view ? this.describe(view) : {}),
|
||||
session_id: s.id,
|
||||
device_alias: s.device.alias,
|
||||
state: "connecting",
|
||||
control: false,
|
||||
};
|
||||
try {
|
||||
s.status = await s.native.request("status", {}, 5000, signal);
|
||||
} catch {
|
||||
if (signal?.aborted) throw new Failure("CANCELLED");
|
||||
s.status = { state: "closed", control: false };
|
||||
}
|
||||
// The user may have disconnected or replaced the session during the read.
|
||||
if (this.current !== s || (view && view.session !== id))
|
||||
return view ? this.describe(view) : { session_id: id, state: "closed", control: false };
|
||||
if (["closed", "failed"].includes(s.status.state)) {
|
||||
await s.native.close();
|
||||
await s.release();
|
||||
}
|
||||
return {
|
||||
...s.status,
|
||||
session_id: s.id,
|
||||
device_alias: s.device.alias,
|
||||
...(view ? this.describe(view) : {}),
|
||||
state: s.status.state,
|
||||
...(s.status.state === "ready" ? {
|
||||
next_step: "Connection ready. Continue the user's pending task within the current authorization. Obtain a screenshot before input; respect human_paused and control. No user confirmation message is needed.",
|
||||
} : ["closed", "failed", "disconnected"].includes(s.status.state) ? {
|
||||
next_step: "The connection ended. Stop waiting and do not automatically reconnect or replay actions.",
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
async waitForConnection(viewId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
|
||||
const deadline = Date.now() + waitMs;
|
||||
let result = this.describe(this.view(viewId));
|
||||
const timedOut = () => ({
|
||||
...result,
|
||||
wait_timed_out: true,
|
||||
next_step: "Still waiting for the user to connect in the panel. Call crossdesk_get_session again with the same view_id and wait_ms: 25000 while the remote task is pending. Do not ask the user to type a connection confirmation.",
|
||||
});
|
||||
const timeout = new AbortController();
|
||||
const timer = waitMs > 0 ? setTimeout(() => timeout.abort(), waitMs) : undefined;
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
||||
try {
|
||||
for (;;) {
|
||||
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||
const view = this.view(viewId);
|
||||
view.lastSeen = Date.now();
|
||||
result = await this.status(undefined, viewId, waitSignal);
|
||||
if (waitMs === 0 || view.panel === "off" ||
|
||||
["ready", "closed", "failed", "disconnected"].includes(result.state))
|
||||
return result;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return timedOut();
|
||||
await delay(Math.min(250, remaining), undefined, { signal: waitSignal });
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||
if (timeout.signal.aborted) return timedOut();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
async connectSaved(
|
||||
name: string,
|
||||
mode: string,
|
||||
display: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Data> {
|
||||
const d = await this.store.device(name);
|
||||
if (
|
||||
!d.authorization ||
|
||||
(mode === "control" && d.authorization !== "control")
|
||||
)
|
||||
throw new Failure("AUTH_REQUIRED");
|
||||
return this.connect(
|
||||
d,
|
||||
await this.store.password(d),
|
||||
mode,
|
||||
display,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
async connectUI(v: View, args: unknown, signal?: AbortSignal): Promise<Data> {
|
||||
const p = parse(uiConnect, args);
|
||||
if (!!p.device_alias === !!p.device_id)
|
||||
throw new Failure("SELECT_ONE_DEVICE");
|
||||
const config = await this.store.config();
|
||||
const d: Device = p.device_alias
|
||||
? { ...(await this.store.device(p.device_alias)) }
|
||||
: {
|
||||
alias: p.label ?? p.device_id!,
|
||||
device_id: p.device_id!,
|
||||
server: p.server ?? config.server,
|
||||
port: p.port ?? config.port,
|
||||
};
|
||||
if (p.device_alias && (p.server || p.port || p.label))
|
||||
throw new Failure("INVALID_ARGUMENT");
|
||||
if ((p.remember_password || p.persistent_authorization) && !p.save_device)
|
||||
throw new Failure("SAVE_DEVICE_REQUIRED");
|
||||
let password = p.password ?? (await this.store.password(d));
|
||||
p.password = undefined;
|
||||
// Persist only after successful authentication, avoiding poisoned saved passwords.
|
||||
const result = await this.connect(
|
||||
d,
|
||||
password,
|
||||
p.mode,
|
||||
p.display_index,
|
||||
v,
|
||||
signal,
|
||||
);
|
||||
if (p.save_device) {
|
||||
d.authorization = p.persistent_authorization ? p.mode : undefined;
|
||||
try {
|
||||
await this.store.save(d, p.remember_password ? password : undefined);
|
||||
} catch (error) {
|
||||
result.persistence_error =
|
||||
error instanceof Failure ? error.code : "CONFIG_WRITE_FAILED";
|
||||
}
|
||||
}
|
||||
password = "";
|
||||
return result;
|
||||
}
|
||||
private async connect(
|
||||
d: Device,
|
||||
password: string,
|
||||
mode: string,
|
||||
display: number,
|
||||
view?: View,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Data> {
|
||||
if (this.closing) throw new Failure("SESSION_CLOSED");
|
||||
if (this.connecting) throw new Failure("BUSY");
|
||||
if (
|
||||
this.current &&
|
||||
!["closed", "failed", "disconnected"].includes(
|
||||
(await this.status(this.current.id)).state,
|
||||
)
|
||||
)
|
||||
throw new Failure("SESSION_LIMIT");
|
||||
this.connecting = true;
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
let native: NativeClient | undefined;
|
||||
try {
|
||||
if (this.current) await this.disconnect(this.current.id);
|
||||
release = await this.store.lock(
|
||||
`device:${d.server}:${d.port}:${d.device_id}`,
|
||||
);
|
||||
this.binary ??= nativeBinary(this.nativePath).catch((e) => {
|
||||
this.binary = undefined;
|
||||
throw e;
|
||||
});
|
||||
const binary = await this.binary;
|
||||
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||
native = new NativeClient(
|
||||
binary,
|
||||
d.server,
|
||||
d.port,
|
||||
join(this.store.directory, "logs"),
|
||||
this.videoOptions ?? (await this.store.config()).video,
|
||||
);
|
||||
const s: Session = {
|
||||
id: token(),
|
||||
device: d,
|
||||
native,
|
||||
status: {},
|
||||
release,
|
||||
};
|
||||
this.current = s;
|
||||
if (view) {
|
||||
view.session = s.id;
|
||||
view.alias = d.alias;
|
||||
view.disconnected = false;
|
||||
}
|
||||
s.status = await native.connect(
|
||||
d.device_id,
|
||||
password,
|
||||
mode,
|
||||
display,
|
||||
signal,
|
||||
);
|
||||
password = "";
|
||||
const deadline = Date.now() + 35000;
|
||||
while (Date.now() < deadline) {
|
||||
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||
s.status = await native.request("status", {}, 5000, signal);
|
||||
if (s.status.state === "ready" && s.status.frame_age_ms <= 2000)
|
||||
return { ...s.status, session_id: s.id, device_alias: s.device.alias };
|
||||
if (["failed", "closed", "disconnected"].includes(s.status.state))
|
||||
throw new Failure(
|
||||
s.status.error === "incorrect_password"
|
||||
? "AUTH_FAILED"
|
||||
: "CONNECTION_FAILED",
|
||||
);
|
||||
await delay(100, undefined, { signal });
|
||||
}
|
||||
throw new Failure("CONNECTION_TIMEOUT");
|
||||
} catch (error) {
|
||||
await native?.close();
|
||||
await release?.();
|
||||
if (this.current?.native === native) this.current = undefined;
|
||||
if (view) view.session = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
password = "";
|
||||
this.connecting = false;
|
||||
}
|
||||
}
|
||||
async screenshot(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const { session_id, ...args } = p;
|
||||
if (args.after_frame_id !== undefined && args.generation === undefined)
|
||||
throw new Failure("GENERATION_REQUIRED");
|
||||
return this.session(session_id).native.request(
|
||||
"screenshot",
|
||||
args,
|
||||
(args.timeout_ms ?? 2000) + 3000,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
async action(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const { session_id, observe_after = true, ...args } = p;
|
||||
const s = this.session(session_id);
|
||||
const result = await s.native.request("action", args, 5000, signal);
|
||||
if (observe_after && result.status === "submitted") {
|
||||
try {
|
||||
result.observation = await this.screenshot(
|
||||
{
|
||||
session_id,
|
||||
generation: result.generation,
|
||||
after_frame_id: result.after_frame_id,
|
||||
timeout_ms: 2000,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
result.observation_error =
|
||||
error instanceof Failure ? error.code : "OBSERVATION_FAILED";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async control(id: string, enabled: boolean) {
|
||||
return this.session(id).native.request("control", { enabled });
|
||||
}
|
||||
async display(id: string, index: number) {
|
||||
return this.session(id).native.request("display", { index });
|
||||
}
|
||||
async disconnect(id: string) {
|
||||
const s = this.session(id);
|
||||
await s.native.close();
|
||||
await s.release();
|
||||
if (this.current === s) this.current = undefined;
|
||||
for (const v of this.views.values())
|
||||
if (v.session === id) {
|
||||
v.session = undefined;
|
||||
v.disconnected = true;
|
||||
}
|
||||
return { session_id: id, state: "closed" };
|
||||
}
|
||||
async viewerControl(v: View, operation: string) {
|
||||
if (!v.session) throw new Failure("NOT_READY");
|
||||
if (operation === "disconnect") return this.disconnect(v.session);
|
||||
return this.session(v.session).native.request("viewer_control", {
|
||||
operation,
|
||||
});
|
||||
}
|
||||
async openVideo(v: View, origin: string): Promise<Data> {
|
||||
if (!v.session || this.views.get(v.id) !== v) throw new Failure("NOT_READY");
|
||||
const s = this.session(v.session);
|
||||
if (this.closing || this.connecting || ["closed", "failed"].includes(s.status.state))
|
||||
throw new Failure("NOT_READY");
|
||||
const generation = v.generation, session = v.session;
|
||||
const ticket = token();
|
||||
const result = await s.native.request("video_open", {
|
||||
ticket, view: v.id, view_generation: generation, session, origin, apps: v.panel === "apps",
|
||||
});
|
||||
if (this.current !== s || this.views.get(v.id) !== v ||
|
||||
v.generation !== generation || v.session !== session) {
|
||||
await s.native.request("video_revoke", { view: v.id }).catch(() => {});
|
||||
throw new Failure("SESSION_CHANGED");
|
||||
}
|
||||
if (result.transport !== "loopback-websocket/1" ||
|
||||
typeof result.url !== "string" || !/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(result.url) ||
|
||||
result.protocol !== (s.native.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
|
||||
result.decoder !== s.native.videoOptions.decoder)
|
||||
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||
return { ...result, ticket };
|
||||
}
|
||||
async revokeVideo(view?: View) {
|
||||
const s = this.current;
|
||||
if (!s || (view && view.session !== s.id)) return;
|
||||
try {
|
||||
await s.native.request("video_revoke", view ? { view: view.id } : {});
|
||||
} catch {
|
||||
// Losing the control channel must not leave an authorized video socket
|
||||
// alive after its view was removed or replaced.
|
||||
if (this.current === s) await this.disconnect(s.id);
|
||||
else await s.native.close();
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
this.closing = true;
|
||||
if (this.current) await this.disconnect(this.current.id);
|
||||
this.views.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
writeFile,
|
||||
rename,
|
||||
unlink,
|
||||
open,
|
||||
chmod,
|
||||
} from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
alias,
|
||||
host,
|
||||
mode,
|
||||
panel,
|
||||
videoOptions,
|
||||
Failure,
|
||||
parse,
|
||||
token,
|
||||
type Data,
|
||||
} from "./common.js";
|
||||
|
||||
const deviceSchema = z.strictObject({
|
||||
alias,
|
||||
device_id: z.string().regex(/^\d{9}$/),
|
||||
server: host,
|
||||
port: z.int().min(1).max(65535),
|
||||
authorization: mode.optional(),
|
||||
credential: z.enum(["keyring", "environment"]).optional(),
|
||||
environment_variable: z
|
||||
.string()
|
||||
.regex(/^[A-Z][A-Z0-9_]{0,127}$/)
|
||||
.optional(),
|
||||
});
|
||||
export type Device = z.infer<typeof deviceSchema>;
|
||||
const configSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
panel: panel.default("auto"),
|
||||
video: videoOptions.prefault({}),
|
||||
server: host.default("api.crossdesk.cn"),
|
||||
port: z.int().min(1).max(65535).default(9099),
|
||||
devices: z.array(deviceSchema).max(256),
|
||||
});
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
export class DeviceStore {
|
||||
readonly directory: string;
|
||||
readonly file: string;
|
||||
constructor(directory?: string) {
|
||||
this.directory = resolve(
|
||||
directory ?? join(homedir(), ".config", "crossdesk", "mcp"),
|
||||
);
|
||||
this.file = join(this.directory, "devices.json");
|
||||
}
|
||||
async initialize() {
|
||||
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== "win32") await chmod(this.directory, 0o700);
|
||||
await mkdir(join(this.directory, "logs"), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
async config(): Promise<Config> {
|
||||
try {
|
||||
return parse(configSchema, JSON.parse(await readFile(this.file, "utf8")));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return configSchema.parse({ version: 1, devices: [] });
|
||||
throw new Failure("CONFIG_INVALID");
|
||||
}
|
||||
}
|
||||
async list(): Promise<Data[]> {
|
||||
return (await this.config()).devices.map((d) => ({
|
||||
device_alias: d.alias,
|
||||
device_id: d.device_id,
|
||||
server: d.server,
|
||||
port: d.port,
|
||||
authorization: d.authorization ?? "none",
|
||||
credential_provider: d.credential ?? "none",
|
||||
}));
|
||||
}
|
||||
async device(name: string): Promise<Device> {
|
||||
const d = (await this.config()).devices.find((d) => d.alias === name);
|
||||
if (!d) throw new Failure("DEVICE_NOT_FOUND");
|
||||
return d;
|
||||
}
|
||||
private account(d: Device) {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([this.directory, d.server, d.port, d.device_id]))
|
||||
.digest("hex");
|
||||
}
|
||||
private async entry(d: Device) {
|
||||
try {
|
||||
const { AsyncEntry } = await import("@napi-rs/keyring");
|
||||
return new AsyncEntry("crossdesk-mcp", this.account(d), {
|
||||
linux: { store: "secret-service" },
|
||||
});
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
async password(d: Device): Promise<string> {
|
||||
if (d.credential === "environment") {
|
||||
const secret =
|
||||
d.environment_variable && process.env[d.environment_variable];
|
||||
if (secret !== undefined) return secret;
|
||||
} else if (d.credential === "keyring") {
|
||||
try {
|
||||
const secret = await (await this.entry(d)).getPassword();
|
||||
if (secret !== null) return secret;
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
throw new Failure("AUTH_REQUIRED");
|
||||
}
|
||||
async save(d: Device, password?: string) {
|
||||
parse(deviceSchema, d);
|
||||
const lock = await this.lock("config");
|
||||
try {
|
||||
const config = await this.config();
|
||||
const previous = config.devices.find((item) => item.alias === d.alias);
|
||||
if (
|
||||
previous &&
|
||||
(previous.device_id !== d.device_id ||
|
||||
previous.server !== d.server ||
|
||||
previous.port !== d.port)
|
||||
)
|
||||
throw new Failure("DEVICE_ALIAS_CONFLICT");
|
||||
if (password !== undefined) {
|
||||
try {
|
||||
await (await this.entry(d)).setPassword(password);
|
||||
d.credential = "keyring";
|
||||
delete d.environment_variable;
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
config.devices = [
|
||||
...config.devices.filter((item) => item.alias !== d.alias),
|
||||
d,
|
||||
];
|
||||
parse(configSchema, config);
|
||||
const temporary = `${this.file}.${token()}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
await rename(temporary, this.file);
|
||||
} finally {
|
||||
await unlink(temporary).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await lock();
|
||||
}
|
||||
}
|
||||
async lock(name: string): Promise<() => Promise<void>> {
|
||||
const path = join(
|
||||
this.directory,
|
||||
`lock-${createHash("sha256").update(name).digest("hex").slice(0, 24)}`,
|
||||
);
|
||||
for (let attempt = 0; attempt < 2; ++attempt) {
|
||||
try {
|
||||
const handle = await open(path, "wx", 0o600);
|
||||
await handle.writeFile(String(process.pid));
|
||||
await handle.close();
|
||||
let released = false;
|
||||
return async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
await unlink(path).catch(() => {});
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
|
||||
throw new Failure("CONFIG_WRITE_FAILED");
|
||||
const pid = Number(await readFile(path, "utf8").catch(() => "0"));
|
||||
if (!Number.isSafeInteger(pid) || pid < 1)
|
||||
throw new Failure("DEVICE_BUSY");
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
throw new Failure("DEVICE_BUSY");
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== "ESRCH")
|
||||
throw new Failure("DEVICE_BUSY");
|
||||
}
|
||||
await unlink(path).catch(() => {});
|
||||
}
|
||||
}
|
||||
throw new Failure("DEVICE_BUSY");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
import { parse, uiConnect, Failure, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
|
||||
export const uiSchemas = {
|
||||
ready: z.strictObject({}),
|
||||
devices: z.strictObject({}),
|
||||
status: z.strictObject({}),
|
||||
connect: uiConnect,
|
||||
video_open: z.strictObject({}),
|
||||
control: z.strictObject({
|
||||
operation: z.enum(["pause", "resume", "disconnect"]),
|
||||
}),
|
||||
};
|
||||
export type UIAction = keyof typeof uiSchemas;
|
||||
export async function uiAction(
|
||||
sessions: Sessions,
|
||||
view: View,
|
||||
name: string,
|
||||
args: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Data> {
|
||||
if (!Object.hasOwn(uiSchemas, name)) throw new Failure("UNKNOWN_ACTION");
|
||||
const p = parse(uiSchemas[name as UIAction], args) as Data;
|
||||
view.lastSeen = Date.now();
|
||||
switch (name) {
|
||||
case "ready":
|
||||
view.ready = true;
|
||||
return sessions.describe(view);
|
||||
case "devices": {
|
||||
const config = await sessions.store.config();
|
||||
return {
|
||||
devices: await sessions.store.list(),
|
||||
defaults: { server: config.server, port: config.port },
|
||||
};
|
||||
}
|
||||
case "status":
|
||||
return sessions.status(undefined, view.id);
|
||||
case "connect":
|
||||
return sessions.connectUI(view, p, signal);
|
||||
case "control":
|
||||
return sessions.viewerControl(view, p.operation);
|
||||
default:
|
||||
throw new Failure("UNKNOWN_ACTION");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user