mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-07 06:39:10 +08:00
[feat] expose system controls and verify remote unlock completion
This commit is contained in:
@@ -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`。旧被控端沿用截图确认流程。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 = ""; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user