mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-07 23:09:21 +08:00
[feat] add persistent remote unlock credentials and local password dialogs
This commit is contained in:
@@ -255,7 +255,7 @@ export class BrowserPanel {
|
||||
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)$/,
|
||||
/^\/api\/(ready|devices|status|connect|video_open|control|unlock)$/,
|
||||
)?.[1];
|
||||
if (!name) throw new Failure("UNKNOWN_ACTION");
|
||||
const { view_id, ...args } = body;
|
||||
|
||||
@@ -46,6 +46,8 @@ export const alias = z
|
||||
.max(80)
|
||||
.regex(/^[\p{L}\p{N} _.-]+$/u);
|
||||
export const mode = z.enum(["observe", "control"]);
|
||||
export const unlockPassword = z.string().min(1).max(256)
|
||||
.refine((value) => Buffer.byteLength(value) <= 256 && !/[\0\r\n\t]/.test(value));
|
||||
export const panel = z.enum(["auto", "apps", "browser", "terminal", "off"]);
|
||||
export type Panel = z.infer<typeof panel>;
|
||||
export const videoOptions = z.strictObject({
|
||||
@@ -138,5 +140,7 @@ export const uiConnect = z.strictObject({
|
||||
port: z.int().min(1).max(65535).optional(),
|
||||
save_device: z.boolean().default(false),
|
||||
remember_password: z.boolean().default(false),
|
||||
unlock_password: unlockPassword.optional(),
|
||||
remember_unlock_password: z.boolean().default(false),
|
||||
persistent_authorization: z.boolean().default(false),
|
||||
});
|
||||
|
||||
+20
-1
@@ -19,6 +19,7 @@ import {
|
||||
failure,
|
||||
uiConnect,
|
||||
videoOptions,
|
||||
unlockPassword,
|
||||
type Panel,
|
||||
} from "./common.js";
|
||||
|
||||
@@ -98,9 +99,27 @@ async function setup(store: DeviceStore, view?: string) {
|
||||
...(environment
|
||||
? { credential: "environment", environment_variable: environment }
|
||||
: {}),
|
||||
...(previous?.unlock_credential ? {
|
||||
unlock_credential: previous.unlock_credential,
|
||||
unlock_environment_variable: previous.unlock_environment_variable,
|
||||
} : {}),
|
||||
};
|
||||
await store.save(d, password);
|
||||
let unlockSecret: string | undefined;
|
||||
if ((await question("配置锁屏密码,供 Agent 后续自动解锁?[y/N]:")).toLowerCase() === "y") {
|
||||
const variable = await question("锁屏密码环境变量名称(留空使用系统凭据库):");
|
||||
if (variable) {
|
||||
d.unlock_credential = "environment";
|
||||
d.unlock_environment_variable = variable;
|
||||
} else {
|
||||
hidden = true;
|
||||
process.stdout.write("系统锁屏密码(隐藏输入):");
|
||||
try { unlockSecret = parse(unlockPassword, await rl.question("")); }
|
||||
finally { hidden = false; process.stdout.write("\n"); }
|
||||
}
|
||||
}
|
||||
await store.save(d, password, unlockSecret);
|
||||
password = undefined;
|
||||
unlockSecret = undefined;
|
||||
process.stdout.write(
|
||||
"已保存。Agent 可调用 crossdesk_list_devices;自动连接仍需有效授权和凭据。\n",
|
||||
);
|
||||
|
||||
@@ -92,6 +92,7 @@ type Pending = {
|
||||
action: boolean;
|
||||
};
|
||||
export class NativeClient {
|
||||
unlockSupported = false;
|
||||
private child: ChildProcess;
|
||||
private pending = new Map<number, Pending>();
|
||||
private sequence = 0;
|
||||
@@ -148,6 +149,7 @@ export class NativeClient {
|
||||
5000,
|
||||
signal,
|
||||
);
|
||||
this.unlockSupported = hello.unlock_protocol === "password/1";
|
||||
if (hello.protocol !== "crossdesk-automation/1" ||
|
||||
hello.terminal_protocol !== "ansi/1" ||
|
||||
hello.video_transport !== "loopback-websocket/1" ||
|
||||
@@ -188,7 +190,7 @@ export class NativeClient {
|
||||
request_id: id,
|
||||
}) + "\n",
|
||||
);
|
||||
reject(new Failure(code, method === "action"));
|
||||
reject(new Failure(code, method === "action" || method === "unlock"));
|
||||
};
|
||||
const abort = () => cancel("CANCELLED");
|
||||
const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout);
|
||||
@@ -196,7 +198,7 @@ export class NativeClient {
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject,
|
||||
action: method === "action",
|
||||
action: method === "action" || method === "unlock",
|
||||
clean: () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
|
||||
+33
-4
@@ -38,6 +38,7 @@ const uiNames: Record<UIAction, string> = {
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
control: "crossdesk_viewer_control",
|
||||
unlock: "crossdesk_ui_unlock",
|
||||
};
|
||||
function output(value: Data, meta?: Data): CallToolResult {
|
||||
const data = { ...value };
|
||||
@@ -68,7 +69,7 @@ export function createServer(
|
||||
{
|
||||
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.",
|
||||
"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. If the remote system is locked, focus its visible password field, take a fresh screenshot and call crossdesk_unlock. A saved system password is used automatically; otherwise the local panel asks the user. Wait with crossdesk_get_session(unlock_request_id, wait_ms: 25000), then verify a fresh screenshot before continuing the original task. 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 = {
|
||||
@@ -123,7 +124,7 @@ export function createServer(
|
||||
inputSchema: schema,
|
||||
annotations: {
|
||||
readOnlyHint: !!d.readOnly,
|
||||
destructiveHint: d.name === "crossdesk_action",
|
||||
destructiveHint: ["crossdesk_action", "crossdesk_unlock"].includes(d.name),
|
||||
openWorldHint: true,
|
||||
},
|
||||
...(d.ui || d.app
|
||||
@@ -236,11 +237,15 @@ export function createServer(
|
||||
session_id: id.optional(),
|
||||
view_id: id.optional(),
|
||||
wait_ms: z.int().min(0).max(25000).optional(),
|
||||
unlock_request_id: id.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.",
|
||||
"Get connection, display geometry, frame age and control state. Supply exactly one of session_id or view_id. With unlock_request_id, wait for that unlock request to finish (default 25000 ms). Otherwise view_id waits for connection and session_id returns immediately. On wait_timed_out, repeat while the task is pending. An unlock state of submitted requires a fresh screenshot to verify success. Waiting never connects or grants control.",
|
||||
async (p, ctx) => {
|
||||
if (!!p.session_id === !!p.view_id)
|
||||
throw new Failure("SELECT_ONE_SESSION");
|
||||
if (p.unlock_request_id) return output(await sessions.waitForUnlock(
|
||||
p.session_id, p.view_id, p.unlock_request_id, p.wait_ms ?? 25000, ctx.mcpReq.signal,
|
||||
));
|
||||
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)
|
||||
@@ -267,6 +272,30 @@ export function createServer(
|
||||
"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_unlock",
|
||||
z.strictObject({
|
||||
session_id: id,
|
||||
operation_id: id,
|
||||
observation_id: id,
|
||||
use_saved: z.boolean().default(true),
|
||||
}),
|
||||
"Unlock the remote system after visually identifying its lock screen, focusing the password field and obtaining a fresh screenshot. Uses its separately saved system password, or asks through the local panel. Never supply passwords in chat or tool arguments. Repeat the same operation_id and parameters only to query that request. Wait with crossdesk_get_session and the returned unlock.request_id, then inspect a fresh screenshot. If the password was rejected, use_saved: false asks the user to replace it. Never automatically retry uncertain or rejected input.",
|
||||
async (p, ctx) => {
|
||||
let v = [...sessions.views.values()].find((view) => view.session === p.session_id);
|
||||
const created = !v;
|
||||
if (!v) v = sessions.create(sessions.choose(supported(ctx)));
|
||||
let result: Data;
|
||||
try { result = await sessions.requestUnlock(p, ctx.mcpReq.signal); }
|
||||
catch (error) { if (created) sessions.views.delete(v.id); throw error; }
|
||||
let panel = sessions.describe(v);
|
||||
try {
|
||||
if (result.unlock.state === "awaiting_password" && !v.ready) panel = await openPanel(v);
|
||||
} catch (error) { panel.panel_error = failure(error); }
|
||||
return viewResult(v, { ...panel, ...result, next_step: result.unlock.next_step });
|
||||
},
|
||||
{ ui: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_set_control",
|
||||
z.strictObject({ session_id: id, enabled: z.boolean() }),
|
||||
@@ -367,7 +396,7 @@ export function createServer(
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: !!d.readOnly,
|
||||
destructiveHint: d.name === "crossdesk_action",
|
||||
destructiveHint: ["crossdesk_action", "crossdesk_unlock"].includes(d.name),
|
||||
openWorldHint: true,
|
||||
},
|
||||
...((d.ui || d.app) && apps(ctx)
|
||||
|
||||
+181
-4
@@ -7,6 +7,7 @@ import {
|
||||
equal,
|
||||
parse,
|
||||
uiConnect,
|
||||
unlockPassword,
|
||||
type Data,
|
||||
type Panel,
|
||||
type VideoOptions,
|
||||
@@ -57,6 +58,23 @@ interface Session {
|
||||
connecting: boolean;
|
||||
statusRequest?: Promise<Data>;
|
||||
closing?: Promise<void>;
|
||||
unlock?: UnlockRequest;
|
||||
unlocks: Map<string, UnlockRequest>;
|
||||
}
|
||||
interface UnlockRequest {
|
||||
id: string;
|
||||
operation: string;
|
||||
observation: string;
|
||||
useSaved: boolean;
|
||||
state: "preparing" | "awaiting_password" | "submitting" | "submitted" | "failed" | "cancelled";
|
||||
expires: number;
|
||||
generation?: number;
|
||||
geometry?: number;
|
||||
error?: Data;
|
||||
result?: Data;
|
||||
persistenceError?: string;
|
||||
saved?: boolean;
|
||||
controller: AbortController;
|
||||
}
|
||||
export class Sessions {
|
||||
readonly views = new Map<string, View>();
|
||||
@@ -92,7 +110,7 @@ export class Sessions {
|
||||
created: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
ready: false,
|
||||
alias,
|
||||
alias: alias ?? this.current?.device.alias,
|
||||
session: this.current?.id,
|
||||
};
|
||||
this.views.set(view.id, view);
|
||||
@@ -170,10 +188,12 @@ export class Sessions {
|
||||
if (["closed", "failed"].includes(s.status.state)) {
|
||||
await this.dispose(s);
|
||||
}
|
||||
this.checkUnlock(s);
|
||||
return {
|
||||
...s.status,
|
||||
session_id: s.id,
|
||||
device_alias: s.device.alias,
|
||||
...(s.unlock ? { unlock: this.unlockStatus(s.unlock) } : {}),
|
||||
...(view ? this.describe(view) : {}),
|
||||
state: s.status.state,
|
||||
...(s.status.state === "ready" ? {
|
||||
@@ -181,6 +201,7 @@ export class Sessions {
|
||||
} : ["closed", "failed", "disconnected"].includes(s.status.state) ? {
|
||||
next_step: "The connection ended. Stop waiting and do not automatically reconnect or replay actions.",
|
||||
} : {}),
|
||||
...(s.unlock && this.unlockPending(s.unlock) ? { next_step: this.unlockStatus(s.unlock).next_step } : {}),
|
||||
};
|
||||
}
|
||||
private async waitStatus(request: Promise<Data>, signal?: AbortSignal) {
|
||||
@@ -197,6 +218,7 @@ export class Sessions {
|
||||
}
|
||||
}
|
||||
private dispose(s: Session): Promise<void> {
|
||||
this.cancelUnlock(s);
|
||||
return s.closing ??= s.native.close().finally(() => s.release());
|
||||
}
|
||||
async waitForConnection(viewId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
|
||||
@@ -267,8 +289,12 @@ export class Sessions {
|
||||
};
|
||||
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)
|
||||
if ((p.remember_password || p.remember_unlock_password || p.persistent_authorization) && !p.save_device)
|
||||
throw new Failure("SAVE_DEVICE_REQUIRED");
|
||||
if (p.remember_unlock_password && !p.unlock_password)
|
||||
throw new Failure("UNLOCK_CREDENTIAL_REQUIRED");
|
||||
if (p.unlock_password && !p.remember_unlock_password)
|
||||
throw new Failure("SAVE_UNLOCK_REQUIRED");
|
||||
let password = p.password ?? (await this.store.password(d));
|
||||
p.password = undefined;
|
||||
// Persist only after successful authentication, avoiding poisoned saved passwords.
|
||||
@@ -282,14 +308,17 @@ export class Sessions {
|
||||
);
|
||||
if (p.save_device) {
|
||||
d.authorization = p.persistent_authorization ? p.mode : undefined;
|
||||
delete d.unlock_credential;
|
||||
delete d.unlock_environment_variable;
|
||||
try {
|
||||
await this.store.save(d, p.remember_password ? password : undefined);
|
||||
await this.store.save(d, p.remember_password ? password : undefined, p.unlock_password);
|
||||
} catch (error) {
|
||||
result.persistence_error =
|
||||
error instanceof Failure ? error.code : "CONFIG_WRITE_FAILED";
|
||||
}
|
||||
}
|
||||
password = "";
|
||||
p.unlock_password = undefined;
|
||||
return result;
|
||||
}
|
||||
private async connect(
|
||||
@@ -342,6 +371,7 @@ export class Sessions {
|
||||
status: {},
|
||||
release,
|
||||
connecting: true,
|
||||
unlocks: new Map(),
|
||||
};
|
||||
this.current = s;
|
||||
if (view) {
|
||||
@@ -408,6 +438,8 @@ export class Sessions {
|
||||
async action(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const { session_id, observe_after = true, ...args } = p;
|
||||
const s = this.session(session_id);
|
||||
this.checkUnlock(s);
|
||||
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
|
||||
const result = await s.native.request("action", args, 5000, signal);
|
||||
if (observe_after && result.status === "submitted") {
|
||||
try {
|
||||
@@ -428,10 +460,14 @@ export class Sessions {
|
||||
return result;
|
||||
}
|
||||
async control(id: string, enabled: boolean) {
|
||||
if (!enabled) this.cancelUnlock(this.session(id));
|
||||
return this.session(id).native.request("control", { enabled });
|
||||
}
|
||||
async display(id: string, index: number) {
|
||||
return this.session(id).native.request("display", { index });
|
||||
const s = this.session(id);
|
||||
this.checkUnlock(s);
|
||||
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
|
||||
return s.native.request("display", { index });
|
||||
}
|
||||
async disconnect(id: string) {
|
||||
const s = this.session(id);
|
||||
@@ -449,10 +485,151 @@ export class Sessions {
|
||||
async viewerControl(v: View, operation: string) {
|
||||
if (!v.session) throw new Failure("NOT_READY");
|
||||
if (operation === "disconnect") return this.disconnect(v.session);
|
||||
if (operation === "pause") this.cancelUnlock(this.session(v.session));
|
||||
return this.session(v.session).native.request("viewer_control", {
|
||||
operation,
|
||||
});
|
||||
}
|
||||
private unlockPending(request: UnlockRequest) {
|
||||
return ["preparing", "awaiting_password", "submitting"].includes(request.state);
|
||||
}
|
||||
private unlockStatus(request: UnlockRequest): Data {
|
||||
return {
|
||||
request_id: request.id,
|
||||
state: request.state,
|
||||
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)
|
||||
? "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"
|
||||
? "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.",
|
||||
};
|
||||
}
|
||||
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.state = "cancelled";
|
||||
request.controller.abort();
|
||||
}
|
||||
private checkUnlock(s: Session) {
|
||||
const request = s.unlock;
|
||||
if (!request || !this.unlockPending(request)) return;
|
||||
if (Date.now() > request.expires) this.cancelUnlock(s, "UNLOCK_EXPIRED");
|
||||
else if (s.closing || !s.status.control || s.status.human_paused ||
|
||||
(request.generation !== undefined && (request.generation !== s.status.generation ||
|
||||
request.geometry !== s.status.geometry_epoch))) this.cancelUnlock(s, "CONTROL_DENIED");
|
||||
}
|
||||
async requestUnlock(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const s = this.session(p.session_id);
|
||||
if (!s.native.unlockSupported) throw new Failure("NATIVE_UNLOCK_UNSUPPORTED");
|
||||
const previous = s.unlocks.get(p.operation_id);
|
||||
if (previous) {
|
||||
if (previous.observation !== p.observation_id || previous.useSaved !== p.use_saved)
|
||||
throw new Failure("OPERATION_ID_CONFLICT");
|
||||
this.checkUnlock(s);
|
||||
return { session_id: s.id, unlock: this.unlockStatus(previous) };
|
||||
}
|
||||
this.checkUnlock(s);
|
||||
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
|
||||
const request: UnlockRequest = {
|
||||
id: p.operation_id, operation: token(), observation: p.observation_id, useSaved: p.use_saved,
|
||||
state: "preparing", expires: Date.now() + 300000, controller: new AbortController(),
|
||||
};
|
||||
s.unlock = request;
|
||||
s.unlocks.set(request.id, request);
|
||||
if (s.unlocks.size > 128) s.unlocks.delete(s.unlocks.keys().next().value!);
|
||||
try {
|
||||
const prepared = await s.native.request("unlock_prepare", { observation_id: p.observation_id }, 5000, signal);
|
||||
request.generation = prepared.generation;
|
||||
request.geometry = prepared.geometry_epoch;
|
||||
let password: string | undefined;
|
||||
if (p.use_saved) {
|
||||
try {
|
||||
const d = await this.store.device(s.device.alias);
|
||||
if (d.device_id !== s.device.device_id || d.server !== s.device.server || d.port !== s.device.port)
|
||||
throw new Failure("DEVICE_ALIAS_CONFLICT");
|
||||
password = await this.store.password(d, "unlock");
|
||||
} catch (error) {
|
||||
if (!["DEVICE_NOT_FOUND", "UNLOCK_CREDENTIAL_REQUIRED", "CREDENTIAL_STORE_UNAVAILABLE"].includes(failure(error).code)) throw error;
|
||||
}
|
||||
}
|
||||
if (!this.unlockPending(request) || signal?.aborted) throw new Failure("CANCELLED");
|
||||
request.state = "awaiting_password";
|
||||
if (password !== undefined) {
|
||||
try { await this.submitUnlock(s, request, password, false, signal); }
|
||||
finally { password = undefined; }
|
||||
} else if (this.policy === "off") throw new Failure("UNLOCK_CREDENTIAL_REQUIRED");
|
||||
} catch (error) {
|
||||
if (request.state !== "cancelled") {
|
||||
request.state = "failed";
|
||||
request.error = failure(error);
|
||||
}
|
||||
}
|
||||
return { session_id: s.id, unlock: this.unlockStatus(request) };
|
||||
}
|
||||
private async submitUnlock(s: Session, request: UnlockRequest, password: string, remember: boolean, signal?: AbortSignal) {
|
||||
parse(unlockPassword, password);
|
||||
await this.status(s.id, undefined, signal);
|
||||
if (s.unlock !== request || request.state !== "awaiting_password") throw new Failure("UNLOCK_EXPIRED");
|
||||
request.state = "submitting";
|
||||
const combined = signal ? AbortSignal.any([signal, request.controller.signal]) : request.controller.signal;
|
||||
try {
|
||||
// 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);
|
||||
const metadata = observation.metadata;
|
||||
if (metadata.generation !== request.generation || metadata.geometry_epoch !== request.geometry)
|
||||
throw new Failure("STALE_OBSERVATION");
|
||||
const result = await s.native.request("unlock", {
|
||||
operation_id: request.operation,
|
||||
observation_id: metadata.observation_id,
|
||||
password,
|
||||
}, 5000, combined);
|
||||
request.result = result;
|
||||
if (request.controller.signal.aborted) return;
|
||||
request.state = result.status === "submitted" ? "submitted" : "failed";
|
||||
if (remember && request.state === "submitted") {
|
||||
try { await this.store.saveUnlock(s.device, password); request.saved = true; }
|
||||
catch (error) { request.persistenceError = failure(error).code; }
|
||||
}
|
||||
} catch (error) {
|
||||
if (!request.controller.signal.aborted) {
|
||||
request.state = "failed";
|
||||
request.error = failure(error);
|
||||
}
|
||||
} finally { password = ""; }
|
||||
}
|
||||
async unlockUI(v: View, p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const s = this.session(v.session!);
|
||||
const request = s.unlock;
|
||||
if (!request || request.id !== p.request_id) throw new Failure("UNLOCK_NOT_FOUND");
|
||||
if (p.cancel) this.cancelUnlock(s);
|
||||
else if (request.state === "awaiting_password") {
|
||||
const password = p.password;
|
||||
p.password = undefined;
|
||||
await this.submitUnlock(s, request, password, p.remember_password, signal);
|
||||
}
|
||||
return { unlock: this.unlockStatus(request) };
|
||||
}
|
||||
async waitForUnlock(sessionId: string | undefined, viewId: string | undefined, requestId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
|
||||
const deadline = Date.now() + waitMs;
|
||||
while (true) {
|
||||
const result = await this.status(sessionId, viewId, signal);
|
||||
const s = result.session_id && this.current?.id === result.session_id ? this.current : undefined;
|
||||
if (!s || ["closed", "failed"].includes(result.state)) return result;
|
||||
const request = s.unlocks.get(requestId);
|
||||
if (!request) throw new Failure("UNLOCK_NOT_FOUND");
|
||||
result.unlock = this.unlockStatus(request);
|
||||
if (!this.unlockPending(request)) return result;
|
||||
if (Date.now() >= deadline) return { ...result, wait_timed_out: true };
|
||||
await delay(Math.min(200, deadline - Date.now()), undefined, { signal });
|
||||
}
|
||||
}
|
||||
async terminalFrame(v: View, args: Data, signal?: AbortSignal) {
|
||||
if (v.panel !== "terminal" || !v.session || this.connecting)
|
||||
throw new Failure("NOT_READY");
|
||||
|
||||
+56
-18
@@ -20,6 +20,7 @@ import {
|
||||
Failure,
|
||||
parse,
|
||||
token,
|
||||
unlockPassword,
|
||||
type Data,
|
||||
} from "./common.js";
|
||||
|
||||
@@ -34,6 +35,8 @@ const deviceSchema = z.strictObject({
|
||||
.string()
|
||||
.regex(/^[A-Z][A-Z0-9_]{0,127}$/)
|
||||
.optional(),
|
||||
unlock_credential: z.enum(["keyring", "environment"]).optional(),
|
||||
unlock_environment_variable: z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/).optional(),
|
||||
});
|
||||
export type Device = z.infer<typeof deviceSchema>;
|
||||
const configSchema = z.strictObject({
|
||||
@@ -76,6 +79,7 @@ export class DeviceStore {
|
||||
port: d.port,
|
||||
authorization: d.authorization ?? "none",
|
||||
credential_provider: d.credential ?? "none",
|
||||
unlock_credential_provider: d.unlock_credential ?? "none",
|
||||
}));
|
||||
}
|
||||
async device(name: string): Promise<Device> {
|
||||
@@ -83,39 +87,45 @@ export class DeviceStore {
|
||||
if (!d) throw new Failure("DEVICE_NOT_FOUND");
|
||||
return d;
|
||||
}
|
||||
private account(d: Device) {
|
||||
private account(d: Device, purpose: "connection" | "unlock") {
|
||||
const identity = [this.directory, d.server, d.port, d.device_id];
|
||||
if (purpose === "unlock") identity.push("unlock");
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([this.directory, d.server, d.port, d.device_id]))
|
||||
.update(JSON.stringify(identity))
|
||||
.digest("hex");
|
||||
}
|
||||
private async entry(d: Device) {
|
||||
private async entry(d: Device, purpose: "connection" | "unlock" = "connection") {
|
||||
try {
|
||||
const { AsyncEntry } = await import("@napi-rs/keyring");
|
||||
return new AsyncEntry("crossdesk-mcp", this.account(d), {
|
||||
return new AsyncEntry("crossdesk-mcp", this.account(d, purpose), {
|
||||
linux: { store: "secret-service" },
|
||||
});
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
async password(d: Device): Promise<string> {
|
||||
if (d.credential === "environment") {
|
||||
async password(d: Device, purpose: "connection" | "unlock" = "connection"): Promise<string> {
|
||||
const provider = purpose === "unlock" ? d.unlock_credential : d.credential;
|
||||
const variable = purpose === "unlock" ? d.unlock_environment_variable : d.environment_variable;
|
||||
if (provider === "environment") {
|
||||
const secret =
|
||||
d.environment_variable && process.env[d.environment_variable];
|
||||
variable && process.env[variable];
|
||||
if (secret !== undefined) return secret;
|
||||
} else if (d.credential === "keyring") {
|
||||
} else if (provider === "keyring") {
|
||||
try {
|
||||
const secret = await (await this.entry(d)).getPassword();
|
||||
const secret = await (await this.entry(d, purpose)).getPassword();
|
||||
if (secret !== null) return secret;
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
throw new Failure("AUTH_REQUIRED");
|
||||
throw new Failure(purpose === "unlock" ? "UNLOCK_CREDENTIAL_REQUIRED" : "AUTH_REQUIRED");
|
||||
}
|
||||
async save(d: Device, password?: string) {
|
||||
async save(d: Device, password?: string, unlockSecret?: string, update: "device" | "unlock" = "device") {
|
||||
parse(deviceSchema, d);
|
||||
if (unlockSecret !== undefined) parse(unlockPassword, unlockSecret);
|
||||
const lock = await this.lock("config");
|
||||
const restore: Array<() => Promise<unknown>> = [];
|
||||
try {
|
||||
const config = await this.config();
|
||||
const previous = config.devices.find((item) => item.alias === d.alias);
|
||||
@@ -126,20 +136,38 @@ export class DeviceStore {
|
||||
previous.port !== d.port)
|
||||
)
|
||||
throw new Failure("DEVICE_ALIAS_CONFLICT");
|
||||
if (update === "unlock") {
|
||||
if (!previous) throw new Failure("SAVE_DEVICE_REQUIRED");
|
||||
d = { ...previous };
|
||||
}
|
||||
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");
|
||||
}
|
||||
d.credential = "keyring";
|
||||
delete d.environment_variable;
|
||||
}
|
||||
// Keep separately configured system credentials when updating connection settings.
|
||||
if (previous && !d.unlock_credential) {
|
||||
d.unlock_credential = previous.unlock_credential;
|
||||
d.unlock_environment_variable = previous.unlock_environment_variable;
|
||||
}
|
||||
if (unlockSecret !== undefined) {
|
||||
d.unlock_credential = "keyring";
|
||||
delete d.unlock_environment_variable;
|
||||
}
|
||||
config.devices = [
|
||||
...config.devices.filter((item) => item.alias !== d.alias),
|
||||
d,
|
||||
];
|
||||
parse(configSchema, config);
|
||||
const saveSecret = async (purpose: "connection" | "unlock", secret: string) => {
|
||||
try {
|
||||
const entry = await this.entry(d, purpose);
|
||||
const previous = await entry.getPassword();
|
||||
restore.push(() => previous === null ? entry.deletePassword() : entry.setPassword(previous));
|
||||
await entry.setPassword(secret);
|
||||
} catch { throw new Failure("CREDENTIAL_STORE_UNAVAILABLE"); }
|
||||
};
|
||||
if (password !== undefined) await saveSecret("connection", password);
|
||||
if (unlockSecret !== undefined) await saveSecret("unlock", unlockSecret);
|
||||
const temporary = `${this.file}.${token()}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
|
||||
@@ -150,10 +178,20 @@ export class DeviceStore {
|
||||
} finally {
|
||||
await unlink(temporary).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
let restored = true;
|
||||
for (const undo of restore.reverse()) {
|
||||
try { await undo(); } catch { restored = false; }
|
||||
}
|
||||
if (!restored) throw new Failure("CREDENTIAL_ROLLBACK_FAILED");
|
||||
throw error;
|
||||
} finally {
|
||||
await lock();
|
||||
}
|
||||
}
|
||||
async saveUnlock(d: Device, password: string) {
|
||||
await this.save(d, undefined, password, "unlock");
|
||||
}
|
||||
async lock(name: string): Promise<() => Promise<void>> {
|
||||
const path = join(
|
||||
this.directory,
|
||||
|
||||
@@ -41,6 +41,34 @@ async function connect(client: TerminalClient) {
|
||||
} finally { rl.close(); }
|
||||
}
|
||||
|
||||
async function unlock(client: TerminalClient, requestId: string, device: string) {
|
||||
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 controller = new AbortController();
|
||||
rl.once("SIGINT", () => controller.abort());
|
||||
const args: Data = { request_id: requestId };
|
||||
try {
|
||||
process.stdout.write("Agent 请求解锁远程系统。请确认远端系统密码框已聚焦。\n系统锁屏密码(隐藏输入,留空取消):");
|
||||
hidden = true;
|
||||
try { args.password = await rl.question("", { signal: controller.signal }); }
|
||||
finally { hidden = false; process.stdout.write("\n"); }
|
||||
if (!args.password) args.cancel = true;
|
||||
else {
|
||||
const { devices } = await client.call("devices");
|
||||
if (devices.some((entry: Data) => entry.device_alias === device))
|
||||
args.remember_password = (await rl.question("保存到系统凭据库并允许以后自动解锁?[y/N]:", { signal: controller.signal })).trim().toLowerCase() === "y";
|
||||
}
|
||||
return await client.call("unlock", args);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) throw error;
|
||||
return await client.call("unlock", { request_id: requestId, cancel: true });
|
||||
} finally { args.password = undefined; rl.close(); }
|
||||
}
|
||||
|
||||
export async function terminalView(directory: string, view: string, cellAspect = 0.5) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.TERM === "dumb")
|
||||
throw new Failure("VIEW_REQUIRES_TERMINAL");
|
||||
@@ -72,7 +100,7 @@ export async function terminalView(directory: string, view: string, cellAspect =
|
||||
process.stdin.resume();
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, stop);
|
||||
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||||
let statusAt = 0, size = "";
|
||||
let statusAt = 0, size = "", promptedUnlock = "";
|
||||
const colors = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") ? 24
|
||||
: /256color/i.test(process.env.TERM ?? "") ? 256 : 8;
|
||||
while (!stopped) {
|
||||
@@ -82,6 +110,25 @@ export async function terminalView(directory: string, view: string, cellAspect =
|
||||
statusAt = Date.now();
|
||||
if (["closed", "failed", "disconnected"].includes(status.state)) break;
|
||||
}
|
||||
if (status.unlock?.state === "awaiting_password" && status.unlock.request_id !== promptedUnlock && !controls) {
|
||||
promptedUnlock = status.unlock.request_id;
|
||||
process.stdin.off("keypress", keypress);
|
||||
process.stdin.setRawMode(false);
|
||||
await write("\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l");
|
||||
try {
|
||||
const result = await unlock(client, promptedUnlock, status.device_alias);
|
||||
message = result.unlock?.error?.code ?? result.unlock?.persistence_error ?? "";
|
||||
} catch (error) { message = failure(error).code; }
|
||||
finally {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.on("keypress", keypress);
|
||||
process.stdin.resume();
|
||||
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||||
statusAt = 0;
|
||||
size = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Leave a spare column so terminals with unusual wrap behavior stay safe.
|
||||
const columns = Math.min(160, Math.max(1, (process.stdout.columns || 80) - 1));
|
||||
const rows = Math.min(60, Math.max(1, (process.stdout.rows || 24) - 2));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { parse, uiConnect, Failure, type Data } from "./common.js";
|
||||
import { parse, uiConnect, unlockPassword, id, Failure, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
|
||||
export const uiSchemas = {
|
||||
@@ -8,6 +8,12 @@ export const uiSchemas = {
|
||||
status: z.strictObject({}),
|
||||
connect: uiConnect,
|
||||
video_open: z.strictObject({}),
|
||||
unlock: z.strictObject({
|
||||
request_id: id,
|
||||
password: unlockPassword.optional(),
|
||||
remember_password: z.boolean().default(false),
|
||||
cancel: z.boolean().default(false),
|
||||
}),
|
||||
control: z.strictObject({
|
||||
operation: z.enum(["pause", "resume", "disconnect"]),
|
||||
}),
|
||||
@@ -40,6 +46,9 @@ export async function uiAction(
|
||||
return sessions.connectUI(view, p, signal);
|
||||
case "control":
|
||||
return sessions.viewerControl(view, p.operation);
|
||||
case "unlock":
|
||||
if (!p.cancel && !p.password) throw new Failure("UNLOCK_CREDENTIAL_REQUIRED");
|
||||
return sessions.unlockUI(view, p, signal);
|
||||
default:
|
||||
throw new Failure("UNKNOWN_ACTION");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user