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:
@@ -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 = ""; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user