[feat] expose system controls and verify remote unlock completion

This commit is contained in:
dijunkun
2026-11-06 20:46:11 +08:00
parent 6bfcd5cf3d
commit dd4baf2fc4
16 changed files with 222 additions and 15 deletions
+6
View File
@@ -262,3 +262,9 @@ npm run pack:npm -- --out-dir /absolute/path/artifacts
`crossdesk_files_transfer` 支持上传和下载文件、文件夹,源路径为绝对路径,目标目录需要已存在。传输使用 `operation_id` 去重,通过 `crossdesk_files_status` 查询每个文件的字节进度和终态,通过 `crossdesk_files_cancel` 取消并清理未完成文件。每个连接同时处理一个传输任务,多文件按现有传输引擎排队。已完成任务保留四份历史记录,状态中的文件清单分页返回。目标同名文件按现有冲突命名规则另存。
文件操作需要更新后的被控端声明 `capabilities.files`。下载请求绑定目标目录及传输标识,接收器接受已为该请求预留的文件。人工暂停后仍可查询或取消已有任务。
### 系统状态与安全桌面
`crossdesk_system_status` 查询系统锁定状态。Windows 同时报告服务可用性和交互桌面阶段。`crossdesk_system_command` 支持 `lock`,Windows 可通过已运行的服务执行 `send_sas`。命令需要控制授权和独立的 `operation_id`,相同请求查询原结果。系统接受命令后,通过状态及画面观察确认实际界面。
支持系统状态的被控端会在密码提交后接受最长八秒的解锁验证。确认解锁后返回 `unlock.state: verified`,并保存用户选择记住的密码。系统状态无法确认时保留 `submitted` 和验证信息。已经解锁的桌面返回 `already_unlocked`。旧被控端沿用截图确认流程。
+1
View File
@@ -175,6 +175,7 @@ export class NativeClient {
const id = ++this.sequence;
const sideEffect = method === "action" || method === "unlock" ||
(method === "clipboard" && args.operation === "set") ||
(method === "system" && args.command !== "status") ||
(method === "files" && ["upload", "download", "cancel"].includes(args.operation));
const line = JSON.stringify({ ...args, id, method }) + "\n";
if (Buffer.byteLength(line) > 65536)
+10 -2
View File
@@ -125,7 +125,7 @@ export function createServer(
annotations: {
readOnlyHint: !!d.readOnly,
destructiveHint: ["crossdesk_action", "crossdesk_unlock", "crossdesk_clipboard_set",
"crossdesk_files_transfer", "crossdesk_files_cancel"].includes(d.name),
"crossdesk_files_transfer", "crossdesk_files_cancel", "crossdesk_system_command"].includes(d.name),
openWorldHint: true,
},
...(d.ui || d.app
@@ -339,6 +339,14 @@ export function createServer(
add("crossdesk_files_cancel", z.strictObject({ session_id: id, operation_id: id }),
"Cancel a transfer and remove its unfinished staging files. Completed files remain available.",
async (p, ctx) => output(await sessions.files({ ...p, operation: "cancel" }, ctx.mcpReq.signal)));
add("crossdesk_system_status", z.strictObject({ session_id: id }),
"Query the remote operating system's lock state and available secure-desktop service. unknown means the platform could not determine the state.",
async (p, ctx) => output(await sessions.system({ ...p, command: "status" }, ctx.mcpReq.signal)),
{ readOnly: true });
add("crossdesk_system_command", z.strictObject({ session_id: id, operation_id: id,
command: z.enum(["lock", "send_sas"]) }),
"Lock the remote session or request Windows secure attention through its service. Requires control authorization. Reuse operation_id to query the same command. Check system status and a fresh screenshot after acceptance.",
async (p, ctx) => output(await sessions.system(p, ctx.mcpReq.signal)));
add("crossdesk_clipboard_set", z.strictObject({ session_id: id, operation_id: id,
text: z.string().max(16384).refine((text) => Buffer.byteLength(text) <= 16384 && !text.includes("\0")) }),
"Set or clear the remote text clipboard. Reuse the same operation_id to query the original result. Returns applied after the remote clipboard update.",
@@ -438,7 +446,7 @@ export function createServer(
annotations: {
readOnlyHint: !!d.readOnly,
destructiveHint: ["crossdesk_action", "crossdesk_unlock", "crossdesk_clipboard_set",
"crossdesk_files_transfer", "crossdesk_files_cancel"].includes(d.name),
"crossdesk_files_transfer", "crossdesk_files_cancel", "crossdesk_system_command"].includes(d.name),
openWorldHint: true,
},
...((d.ui || d.app) && apps(ctx)
+44 -6
View File
@@ -66,7 +66,8 @@ interface UnlockRequest {
operation: string;
observation: string;
useSaved: boolean;
state: "preparing" | "awaiting_password" | "submitting" | "submitted" | "failed" | "cancelled";
state: "preparing" | "awaiting_password" | "submitting" | "verifying" | "verified" | "submitted" | "failed" | "cancelled";
verification?: "locked" | "unlocked" | "unknown";
expires: number;
generation?: number;
geometry?: number;
@@ -501,6 +502,13 @@ export class Sessions {
throw error;
}
}
async system(p: Data, signal?: AbortSignal): Promise<Data> {
const { session_id, ...args } = p;
const s = this.session(session_id);
if (args.command !== "status" && s.unlock && this.unlockPending(s.unlock))
throw new Failure("UNLOCK_PENDING");
return s.native.request("system", args, 7000, signal);
}
async control(id: string, enabled: boolean) {
if (!enabled) this.cancelUnlock(this.session(id));
return this.session(id).native.request("control", { enabled });
@@ -533,20 +541,23 @@ export class Sessions {
});
}
private unlockPending(request: UnlockRequest) {
return ["preparing", "awaiting_password", "submitting"].includes(request.state);
return ["preparing", "awaiting_password", "submitting", "verifying"].includes(request.state);
}
private unlockStatus(request: UnlockRequest): Data {
return {
request_id: request.id,
state: request.state,
...(request.verification ? { verification: request.verification } : {}),
expires_at: request.expires,
...(request.error ? { error: request.error } : {}),
...(request.result ? { result: request.result } : {}),
...(request.persistenceError ? { persistence_error: request.persistenceError } : {}),
...(request.saved ? { credential_saved: true } : {}),
next_step: this.unlockPending(request)
next_step: request.state === "verifying"
? "The remote system is verifying unlock completion. Wait with crossdesk_get_session and unlock_request_id."
: this.unlockPending(request)
? "The user enters the system lock-screen password in the local panel. Never request it in chat. Wait with crossdesk_get_session and unlock_request_id."
: request.state === "submitted"
: ["submitted", "verified"].includes(request.state)
? "Take a fresh screenshot to verify that the desktop is unlocked, then continue the pending task. If the password was rejected, request user entry with use_saved: false; never automatically replay an unlock."
: "Unlock stopped. Inspect the error and current screen before starting another request. Never automatically replay an uncertain submission.",
};
@@ -554,7 +565,7 @@ export class Sessions {
private cancelUnlock(s: Session, code = "CANCELLED") {
const request = s.unlock;
if (!request || !this.unlockPending(request)) return;
request.error = failure(new Failure(code, request.state === "submitting"));
request.error = failure(new Failure(code, ["submitting", "verifying"].includes(request.state)));
request.state = "cancelled";
request.controller.abort();
}
@@ -621,6 +632,17 @@ export class Sessions {
request.state = "submitting";
const combined = signal ? AbortSignal.any([signal, request.controller.signal]) : request.controller.signal;
try {
const verify = s.status.capabilities?.system === true;
if (verify) {
const before = await this.system({ session_id: s.id, command: "status" }, combined);
if (combined.aborted) return;
if (before.lock_state === "unlocked") {
request.state = "verified";
request.verification = "unlocked";
request.result = { status: "already_unlocked" };
return;
}
}
// User input may take longer than an observation's lifetime. Re-observe the
// same display generation before sending the secret to its focused field.
const observation = await this.screenshot({ session_id: s.id }, combined);
@@ -635,14 +657,30 @@ export class Sessions {
request.result = result;
if (request.controller.signal.aborted) return;
request.state = result.status === "submitted" ? "submitted" : "failed";
if (remember && request.state === "submitted") {
if (verify && request.state === "submitted") {
request.state = "verifying";
const deadline = Date.now() + 8000;
do {
const state = await this.system({ session_id: s.id, command: "status" }, combined);
request.verification = ["locked", "unlocked"].includes(state.lock_state) ? state.lock_state : "unknown";
if (request.verification !== "locked") break;
await delay(250, undefined, { signal: combined });
} while (Date.now() < deadline);
if (request.controller.signal.aborted) return;
request.state = request.verification === "unlocked" ? "verified" : "submitted";
if (request.verification === "locked") request.error = failure(new Failure("UNLOCK_NOT_CONFIRMED", true));
}
if (remember && (request.state === "verified" || (!verify && request.state === "submitted"))) {
try { await this.store.saveUnlock(s.device, password); request.saved = true; }
catch (error) { request.persistenceError = failure(error).code; }
} else if (remember && request.state === "submitted") {
request.persistenceError = "UNLOCK_NOT_CONFIRMED";
}
} catch (error) {
if (!request.controller.signal.aborted) {
request.state = "failed";
request.error = failure(error);
if (request.result?.status === "submitted") request.error.side_effect_possible = true;
}
} finally { password = ""; }
}
+7 -5
View File
@@ -142,6 +142,7 @@ const messages: Record<string, string> = {
UNLOCK_PENDING: "请先完成或取消当前解锁请求。",
UNLOCK_CREDENTIAL_REQUIRED: "请输入系统锁屏密码。",
UNLOCK_EXPIRED: "解锁请求已失效,请让 Agent 重新观察锁屏并发起请求。",
UNLOCK_NOT_CONFIRMED: "系统解锁状态尚待确认,请检查远端画面。",
NATIVE_UNLOCK_UNSUPPORTED: "请更新原生 CLI,以启用锁屏解锁。",
SAVE_DEVICE_REQUIRED: "请先保存设备,再保存锁屏密码。",
SAVE_UNLOCK_REQUIRED: "请勾选保存锁屏密码。",
@@ -285,7 +286,7 @@ function renderUnlock(s: Data) {
const sessionChanged = s.session_id !== unlockSessionId;
const changed = sessionChanged || request?.request_id !== unlockRequestId;
const stateChanged = changed || request?.state !== unlockState;
const finished = ["submitted", "cancelled", "failed"].includes(request?.state);
const finished = ["submitted", "verified", "cancelled", "failed"].includes(request?.state);
const dialog = element<HTMLDialogElement>("unlock-dialog");
if (changed || !pending || !visible) {
input("unlock-password").value = "";
@@ -305,7 +306,7 @@ function renderUnlock(s: Data) {
element("session-unlock-row").hidden = !visible;
const states: Record<string, string> = {
preparing: "准备中", awaiting_password: "等待密码", submitting: "提交中",
submitted: "已提交", cancelled: "已取消", failed: "提交失败",
submitted: "已提交", verifying: "验证中", verified: "已解锁", cancelled: "已取消", failed: "提交失败",
};
text("session-unlock-state", states[request?.state] ?? "—");
element("session-unlock-state").dataset.state = request?.state ?? "";
@@ -320,12 +321,13 @@ function renderUnlock(s: Data) {
? "正在提交解锁,请稍候。" : "请确认远端系统密码框已聚焦,然后输入锁屏密码。"));
if (visible && pending && stateChanged) openUnlockDialog();
if (visible && finished) {
text("unlock-toast-title", request.state === "submitted" ? "解锁请求已提交"
text("unlock-toast-title", request.state === "verified" ? "系统已解锁" : request.state === "submitted" ? "解锁请求已提交"
: request.state === "cancelled" ? "解锁已取消" : "解锁操作未完成");
let message = request.state === "submitted" ? "Agent 将检查画面并继续任务。" : "";
let message = ["submitted", "verified"].includes(request.state) ? "Agent 将检查画面并继续任务。" : "";
if (request.credential_saved) message = "锁屏密码已保存,Agent 将检查画面并继续任务。";
if (request.error) message = messages[request.error.code] ?? "请检查远端画面后再发起解锁。";
if (request.persistence_error) message = "密码保存失败,可通过本机 setup 重新配置。";
if (request.persistence_error) message = request.persistence_error === "UNLOCK_NOT_CONFIRMED"
? "锁屏密码需要在解锁确认后保存,请检查远端画面。" : "密码保存失败,可通过本机 setup 重新配置。";
text("unlock-toast-message", message);
element("unlock-toast-message").hidden = !message;
element("unlock-toast").dataset.state = request.state;