[feat] add persistent remote unlock credentials and local password dialogs

This commit is contained in:
dijunkun
2026-11-06 20:03:05 +08:00
parent 2535a04d2b
commit a49eb7ad5b
17 changed files with 601 additions and 52 deletions
+1 -1
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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",
);
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+48 -1
View File
@@ -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));
+10 -1
View File
@@ -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");
}