[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
+37 -16
View File
@@ -277,6 +277,8 @@ private:
break;
}
Reply(action_->id, result);
for (auto &step : action_->steps)
std::fill(step.text.begin(), step.text.end(), '\0');
action_.reset();
}
void Stop(const char *code) {
@@ -323,7 +325,7 @@ private:
FinishAction("SESSION_CHANGED");
while (action_ && action_->next < action_->steps.size() &&
Age(action_->started) >= action_->steps[action_->next].delay) {
const auto &step = action_->steps[action_->next++];
auto &step = action_->steps[action_->next++];
action_->submitted =
true; // A failed send may still have reached the peer.
const bool sent =
@@ -331,6 +333,7 @@ private:
? session_.Pointer(step.x, step.y, step.flag, step.wheel)
: step.kind == StepKind::Key ? session_.Key(step.key, step.down)
: session_.Text(step.text);
std::fill(step.text.begin(), step.text.end(), '\0');
if (!sent) {
FinishAction("ACTION_OUTCOME_UNKNOWN");
break;
@@ -546,13 +549,26 @@ private:
throw std::invalid_argument("STALE_OBSERVATION");
return *it;
}
void StartAction(const json &j, const json &id) {
Fields(j, {"id", "method", "operation_id", "observation_id", "action"});
const Observation &InputObservation(const json &j) {
if (!allowed_ || paused_ || !session_.ControlEnabled())
throw std::invalid_argument("CONTROL_DENIED");
if (!session_.Connected() || !frame_ || Age(frame_->queued) > 2000)
throw std::invalid_argument("NOT_READY");
if (action_)
throw std::invalid_argument("BUSY");
return Observe(j);
}
void StartAction(const json &j, const json &id, bool unlock = false) {
if (unlock)
Fields(j, {"id", "method", "operation_id", "observation_id", "password"});
else
Fields(j, {"id", "method", "operation_id", "observation_id", "action"});
const auto operation = String(j, "operation_id");
// A private unlock's credential is never retained in the operation cache.
// Repeating its ID retrieves the first attempt, including uncertain results.
const auto arguments = json{
{"observation_id", j.at("observation_id")},
{"action",
j.at("action")}}.dump();
{"action", unlock ? json{{"type", "unlock"}} : j.at("action")}}.dump();
for (const auto &r : records_)
if (r.operation == operation) {
if (r.arguments != arguments)
@@ -560,15 +576,8 @@ private:
Reply(id, r.result);
return;
}
if (!allowed_ || paused_ || !session_.ControlEnabled())
throw std::invalid_argument("CONTROL_DENIED");
if (!session_.Connected() || !frame_ || Age(frame_->queued) > 2000)
throw std::invalid_argument("NOT_READY");
if (action_)
throw std::invalid_argument("BUSY");
const auto &o = Observe(j);
const auto &a = j.at("action");
const auto type = String(a, "type", 32);
const auto &o = InputObservation(j);
const auto type = unlock ? "type_text" : String(j.at("action"), "type", 32);
auto running = std::make_unique<RunningAction>();
running->id = id;
running->operation = operation;
@@ -576,8 +585,9 @@ private:
running->epoch = o.epoch;
if (type == "type_text" && !session_.Status()["text_input"].get<bool>())
throw std::invalid_argument("TEXT_INPUT_UNSUPPORTED");
running->steps = automation::BuildInputSteps(a,
{o.width, o.height, o.source_width, o.source_height, o.region});
running->steps = unlock
? BuildUnlockSteps(String(j, "password", 256), session_.Status().value("platform", "") == "macos")
: BuildInputSteps(j.at("action"), {o.width, o.height, o.source_width, o.source_height, o.region});
while (records_.size() >= 128 ||
(!records_.empty() && Age(records_.front().created) > 300000))
records_.pop_front();
@@ -632,6 +642,7 @@ private:
{"video_decoders", {"auto", "software"}},
{"video_transport", "loopback-websocket/1"},
{"terminal_protocol", "ansi/1"},
{"unlock_protocol", "password/1"},
{"action_completion", "submitted"},
{"max_request_bytes", 65536},
{"max_response_bytes", kMaxResponse},
@@ -677,6 +688,16 @@ private:
} else if (method == "action") {
StartAction(j, id);
return false;
} else if (method == "unlock_prepare") {
Fields(j, {"id", "method", "observation_id"});
const auto &observation = InputObservation(j);
if (!session_.Status()["text_input"].get<bool>())
throw std::invalid_argument("TEXT_INPUT_UNSUPPORTED");
Reply(id, {{"generation", observation.generation}, {"geometry_epoch", observation.epoch}});
return false;
} else if (method == "unlock") {
StartAction(j, id, true);
return false;
} else if (method == "terminal_frame") {
Fields(j, {"id", "method", "columns", "rows", "colors", "cell_aspect"});
if (terminal_encoding_) throw std::invalid_argument("BUSY");
+14
View File
@@ -259,4 +259,18 @@ std::vector<Step> BuildInputSteps(const json &a, const InputGeometry &o) {
}
return steps;
}
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos) {
if (password.empty() || password.size() > 256 ||
password.find('\0') != std::string::npos || password.find_first_of("\r\n\t") != std::string::npos)
throw std::invalid_argument("INVALID_CREDENTIAL");
const InputGeometry geometry{};
auto steps = BuildInputSteps({{"type", "hotkey"}, {"keys", {macos ? "META" : "CTRL", "A"}}}, geometry);
auto text = BuildInputSteps({{"type", "type_text"}, {"text", password}}, geometry);
text.front().delay = 150;
steps.insert(steps.end(), text.begin(), text.end());
auto enter = BuildInputSteps({{"type", "key_press"}, {"key", "ENTER"}}, geometry);
for (auto &step : enter) step.delay += 350;
steps.insert(steps.end(), enter.begin(), enter.end());
return steps;
}
} // namespace crossdesk::cli::automation
+1
View File
@@ -36,6 +36,7 @@ struct Step {
// Pure validation and translation: no session access or input side effects.
std::vector<Step> BuildInputSteps(const json &action,
const InputGeometry &geometry);
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos);
} // namespace crossdesk::cli::automation
#endif
+12
View File
@@ -33,6 +33,8 @@ The output queue is bounded to 64 messages and 8 MiB; a blocked output pipe term
| `video_revoke` | optional `view`; omit to revoke all video grants and sockets |
| `preview` | optional `max_edge` (64–960), `after_frame_id`, `generation`, `timeout_ms` (0–5000); no region |
| `action` | `operation_id`, `observation_id`, `action` |
| `unlock_prepare` | `observation_id`; validates control, fresh frames and text-input support, returns `generation` and `geometry_epoch` |
| `unlock` | `operation_id`, `observation_id`, `password`; trusted local credential submission to the focused system password field |
| `control` | `enabled` boolean; cannot override observe-only permission or human pause |
| `viewer_control` | `operation: "pause" | "resume" | "disconnect"`; restricted to trusted UI by the MCP adapter |
| `terminal_frame` | `columns` (1–160), `rows` (1–60), `colors` (8/24/256), `cell_aspect` (0.2–2); private terminal viewer only |
@@ -123,3 +125,13 @@ New actions require a current frame no older than two seconds as well as a valid
Results use `status: submitted | in_progress | failed_before_submit | outcome_unknown`, with `operation_id`, `retryable`, `side_effect_possible` and, for completed actions, `generation`/`after_frame_id`. The same operation ID and arguments retrieve the cached result; different arguments conflict. Retention is bounded to 128 entries, with entries older than five minutes eligible for removal on a new action. Never retry an uncertain effect with a new ID. `submitted` does not claim remote execution.
The native process trusts its parent. The parent must authenticate panel-only `viewer_control` calls and protect descriptor 3; this private pipe does not isolate arbitrary programs running as the same OS user.
## System unlock
`doctor` advertises `automation_unlock_protocol: "password/1"`; `hello` advertises `unlock_protocol: "password/1"`. The adapter enables the additional unlock flow when this capability is present.
After the Agent identifies the system lock screen and focuses its password field, `unlock_prepare` validates the supplied observation and returns its display generation. The trusted adapter may then ask the user in its local panel, or retrieve the separately configured system credential. Before submission it captures a fresh observation and verifies that the display generation and geometry still match.
`unlock` accepts a password of 1–256 UTF-8 bytes, rejecting NUL, newline, carriage return and tab. It uses the existing input scheduler to select the focused field contents, type the password and press Enter. Selection uses Command+A on macOS and Ctrl+A on other platforms. Control permission, human pause, observation freshness and cancellation follow the action rules. The operation cache stores the observation and unlock operation kind, with secret text confined to the transient input request and scheduled text step. Completed or cancelled steps clear their text buffers.
The result follows the existing action result format. Repeated operation IDs retrieve the first result, and the caller verifies the visible desktop using a subsequent screenshot. Password persistence and the five-minute user-input request lifetime belong to the TypeScript adapter.
+1
View File
@@ -1093,6 +1093,7 @@ int main(int argc, char **argv) {
{"bridge_version", 1},
{"automation_terminal_protocol", "ansi/1"},
{"automation_protocol", "crossdesk-automation/1"},
{"automation_unlock_protocol", "password/1"},
{"automation_video", "h264-annexb/1"},
{"automation_video_transport", "loopback-websocket/1"},
{"automation_video_protocols", {"h264-annexb/1", "nv12/1"}},
+13
View File
@@ -160,6 +160,7 @@ Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见
| `crossdesk_get_session` | `session_id`、`view_id` 严格二选一;按 `view_id` 可等待连接,`wait_ms` 范围 0–25000 |
| `crossdesk_screenshot` | 返回 PNG image content 和观测元数据 |
| `crossdesk_action` | 结构化输入,可附带动作之后的截图 |
| `crossdesk_unlock` | 使用独立的系统锁屏凭据解锁,或在本地面板请求输入密码 |
| `crossdesk_set_control` | 在已授权范围内启停 Agent 输入 |
| `crossdesk_select_display` | 切换显示器,使旧观测失效 |
| `crossdesk_disconnect` | 断开并释放输入状态、子进程和设备锁 |
@@ -178,6 +179,18 @@ Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见
`operation_id` 在当前原生会话中缓存最近 128 个操作,至少在未被容量淘汰的 5 分钟窗口内支持同参数查询;不同参数复用 ID 返回冲突。`submitted` 仅说明本地发送完成,远端没有执行 ACK。超时、断网、取消或发送失败可能返回 `outcome_unknown`/`side_effect_possible`,禁止自动重放。`observe_after` 默认开启,后续图像只证明取得更新帧,不证明操作执行成功。
### 锁屏解锁与密码保存
Agent 识别到远端系统锁屏后,先聚焦画面中的系统密码框并重新截图,再调用 `crossdesk_unlock`,传入 `session_id`、`operation_id` 和截图的 `observation_id`。默认 `use_saved: true` 会读取该设备独立保存的锁屏凭据。缺少凭据时,浏览器或 Apps 面板通过悬浮弹窗显示隐藏输入框,终端观看器显示隐藏输入提示。弹窗可关闭,并通过左侧连接信息中的“输入密码”重新打开。提交结果显示为六秒后收起的悬浮提示,连接信息持续展示解锁状态和锁屏凭据配置状态。
用户可在解锁输入框勾选“保存密码并允许以后自动解锁”。连接表单的“保存与授权”和本机 `crossdesk-mcp setup` 也提供锁屏密码配置。持久保存要求设备已在本地设备簿中,系统凭据库使用独立账户保存访问密码和锁屏密码。设备列表返回 `unlock_credential_provider` 摘要,配置文件保存 `unlock_credential` 引用。环境变量方式使用 `unlock_credential: "environment"` 和 `unlock_environment_variable`,旧设备配置继续有效。
等待输入时,Agent 使用 `crossdesk_get_session`,提供 `session_id` 或 `view_id`、`unlock_request_id` 和 `wait_ms: 25000`。`wait_timed_out` 后可继续等待,等待取消仅结束当前查询。解锁请求有效期为五分钟,等待期间保留实时画面,常规 Agent 输入和显示器切换会返回 `UNLOCK_PENDING`。用户取消、人工暂停、连接结束或画面代际变化会终止请求。
提交前重新获取同一显示器代际的截图,原生端执行选择密码框内容、文本输入和回车。成功发送返回 `unlock.state: "submitted"`,Agent 随后用新截图确认桌面是否已解锁,再继续原任务。保存选项在输入提交后写入凭据库,密码正确性仍需检查远端画面。密码被拒绝时,使用新请求并设置 `use_saved: false` 让用户更正,结果不确定时先观察画面。相同 `operation_id` 与参数查询原请求结果。
锁屏密码通过经过认证的本地面板或终端 IPC 进入原生输入通道。模型侧工具参数和结果只包含请求状态,原生操作缓存仅保存解锁请求标识及结果。解锁遵守现有控制授权和人工暂停状态,依赖远端文本输入能力。`off` 模式可使用预先配置的锁屏凭据,缺少凭据时返回 `UNLOCK_CREDENTIAL_REQUIRED`。原生 CLI 通过 `unlock_protocol: "password/1"` 声明支持,更新源码后需重建 CLI 和 MCP 并重新启动服务。
## 实时视频与按需截图
实时视频提供两条路径,默认 `browser`,两条路径均由原生 CLI 的本机 WebSocket 直接发送给浏览器。Node 只处理会话、授权票据和 MCP 工具,不读取或转发实时视频字节;不再创建视频管道,不经过 MCP stdio 或模型上下文,不逐帧转 PNG、不重新编码视频。
+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");
}
+16
View File
@@ -70,6 +70,22 @@ button:disabled { cursor: default; color: #a3a7ad; background: #fafafa; border-c
#session-agent[data-paused="true"] { color: #956e2d; }
.session-note { margin-top: 18px; }
.workspace { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; overflow: auto; padding: 16px; background: #f6f7f8; }
.session-unlock-details { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; }
#session-unlock-state[data-state="awaiting_password"] { color: #956e2d; }
#session-unlock-state[data-state="failed"] { color: #9a4e47; }
.unlock-dialog { width: min(420px, calc(100% - 32px)); max-height: calc(100% - 32px); margin: auto; padding: 22px; color: inherit; background: #fff; border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 18px 64px #17243733; }
.unlock-dialog::backdrop { background: #19212e4d; }
.unlock-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.unlock-dialog-header h2 { font-size: 16px; }
.unlock-dialog-header button, .unlock-toast > button { width: 28px; min-height: 28px; padding: 0; flex: none; border: 0; color: var(--muted); font-size: 20px; line-height: 1; }
.unlock-dialog p { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.unlock-buttons { display: flex; gap: 8px; }
.unlock-buttons .primary { margin: 0; width: auto; min-width: 80px; }
.unlock-toast { position: fixed; top: 70px; right: 20px; z-index: 10; display: flex; align-items: flex-start; gap: 12px; width: min(360px, calc(100% - 32px)); padding: 14px 14px 14px 16px; border: 1px solid var(--line); border-radius: 8px; background: #fff; box-shadow: 0 6px 24px #17243726; }
.unlock-toast > div { min-width: 0; flex: 1; }
.unlock-toast strong { display: block; padding-top: 4px; font-size: 13px; font-weight: 600; }
.unlock-toast p { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.unlock-toast[data-state="failed"] { border-color: #d6b4ae; }
.viewer-card { display: flex; flex-direction: column; flex: 1; min-height: 160px; min-width: 0; background: #fff; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
.viewer-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 9px 12px; flex: none; }
.viewer-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
+150 -4
View File
@@ -30,8 +30,10 @@ root.innerHTML = `
<div class="checks">
<label><input id="save" type="checkbox"><span>保存设备</span></label>
<label><input id="remember" type="checkbox" disabled><span>记住密码<small>保存在本机系统凭据库</small></span></label>
<label><input id="remember-unlock" type="checkbox" disabled><span>保存锁屏密码并允许自动解锁<small>独立保存在本机系统凭据库</small></span></label>
<label><input id="authorize" type="checkbox" disabled><span>允许下次自动连接<small>沿用本次授权范围</small></span></label>
</div>
<div id="saved-unlock-field" hidden><label for="saved-unlock-password">系统锁屏密码</label><input id="saved-unlock-password" type="password" autocomplete="off" maxlength="256"></div>
</details>
<details id="advanced"><summary>高级设置</summary>
<label for="server">服务器</label><input id="server" value="api.crossdesk.cn" autocomplete="off">
@@ -43,7 +45,7 @@ root.innerHTML = `
</div>
<div id="session-card" hidden>
<div class="device-card"><strong id="session-device">远程电脑</strong><span id="session-platform">已连接</span></div>
<dl class="session-details"><div id="session-device-id-row" hidden><dt>设备 ID</dt><dd id="session-device-id"></dd></div><div><dt>访问权限</dt><dd id="session-access"></dd></div><div><dt>显示器</dt><dd id="session-display"></dd></div><div><dt>Agent 状态</dt><dd id="session-agent"></dd></div></dl>
<dl class="session-details"><div id="session-device-id-row" hidden><dt>设备 ID</dt><dd id="session-device-id"></dd></div><div><dt>访问权限</dt><dd id="session-access"></dd></div><div><dt>显示器</dt><dd id="session-display"></dd></div><div><dt>Agent 状态</dt><dd id="session-agent"></dd></div><div><dt>锁屏密码</dt><dd id="session-unlock-credential"></dd></div><div id="session-unlock-row" hidden><dt>解锁状态</dt><dd class="session-unlock-details"><span id="session-unlock-state"></span><button id="unlock-open" type="button" hidden>输入密码</button></dd></div></dl>
<p class="session-note">关闭页面不会断开连接。</p>
</div>
</aside>
@@ -65,7 +67,17 @@ root.innerHTML = `
<div class="video-options"><button id="video-retry" hidden>重新加载画面</button><button id="video-browser" hidden>在浏览器中观看</button></div>
<p id="panel-status" class="panel-status" role="status" hidden></p>
</section>
</main>`;
</main>
<dialog id="unlock-dialog" class="unlock-dialog" aria-labelledby="unlock-title" aria-describedby="unlock-status">
<div class="unlock-dialog-header"><h2 id="unlock-title">解锁远程电脑</h2><button id="unlock-close" type="button" aria-label="关闭解锁弹窗">×</button></div>
<p id="unlock-status" role="status"></p>
<form id="unlock-form">
<label for="unlock-password">系统锁屏密码</label><input id="unlock-password" type="password" autocomplete="off" maxlength="256" required autofocus>
<div class="checks"><label><input id="unlock-remember" type="checkbox"><span>保存密码并允许以后自动解锁<small>独立保存在本机系统凭据库</small></span></label></div>
<div class="unlock-buttons"><button id="unlock-submit" class="primary" type="submit">解锁</button><button id="unlock-cancel" type="button">取消解锁</button></div>
</form>
</dialog>
<div id="unlock-toast" class="unlock-toast" role="status" hidden><div><strong id="unlock-toast-title"></strong><p id="unlock-toast-message"></p></div><button id="unlock-toast-close" type="button" aria-label="关闭解锁提示">×</button></div>`;
const element = <T extends HTMLElement = HTMLElement>(id: string) =>
document.getElementById(id) as T;
const input = (id: string) => element<HTMLInputElement>(id);
@@ -76,6 +88,12 @@ let context: Data | undefined;
let status: Data = {};
let savedDevices: Data[] = [];
let controlBusy = false;
let unlockBusy = false;
let unlockRequestId: string | undefined;
let unlockSessionId: string | undefined;
let unlockState: string | undefined;
let unlockError = "";
let unlockToastTimer: ReturnType<typeof setTimeout> | undefined;
let disconnectedByUser = false;
let frameSize = "", frameRate = "";
let player: VideoPlayer | undefined;
@@ -115,11 +133,18 @@ const messages: Record<string, string> = {
SESSION_LIMIT: "已有活动连接,请先断开当前电脑。",
DEVICE_BUSY: "另一实例正在使用此设备。",
CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。",
CREDENTIAL_ROLLBACK_FAILED: "凭据保存中断,请通过本机 setup 重新配置密码。",
PAIRING_REQUIRED:
"请在终端运行 Agent 工具结果中的 setup --open-panel 配对命令,再打开面板。",
PANEL_UNAUTHORIZED: "面板授权已失效,请重新打开。",
VIEW_UNAUTHORIZED: "此面板已失效,请使用最新打开的面板。",
CONTROL_DENIED: "当前会话没有控制权限,或已被人工暂停。",
UNLOCK_PENDING: "请先完成或取消当前解锁请求。",
UNLOCK_CREDENTIAL_REQUIRED: "请输入系统锁屏密码。",
UNLOCK_EXPIRED: "解锁请求已失效,请让 Agent 重新观察锁屏并发起请求。",
NATIVE_UNLOCK_UNSUPPORTED: "请更新原生 CLI,以启用锁屏解锁。",
SAVE_DEVICE_REQUIRED: "请先保存设备,再保存锁屏密码。",
SAVE_UNLOCK_REQUIRED: "请勾选保存锁屏密码。",
NATIVE_PACKAGE_MISSING:
"缺少对应平台的 CLI 运行组件,请检查包安装是否启用了 optional dependencies。",
INVALID_ARGUMENT: "请检查设备 ID、服务器和输入内容。",
@@ -156,7 +181,7 @@ function setDetails(open: boolean) {
element("details-toggle").onclick = () => setDetails(!root.classList.contains("details-open"));
element("focus-view").onclick = () => setFocus(!root.classList.contains("focus-mode"));
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && root.classList.contains("focus-mode")) setFocus(false);
if (event.key === "Escape" && !element<HTMLDialogElement>("unlock-dialog").open && root.classList.contains("focus-mode")) setFocus(false);
});
function hidePassword() {
input("password").type = "password";
@@ -223,10 +248,122 @@ function deviceChanged() {
}
select("device").onchange = deviceChanged;
input("save").onchange = () => {
for (const name of ["remember", "authorize"]) {
for (const name of ["remember", "remember-unlock", "authorize"]) {
input(name).disabled = !input("save").checked;
if (!input("save").checked) input(name).checked = false;
}
savedUnlockChanged();
};
function savedUnlockChanged() {
element("saved-unlock-field").hidden = !input("remember-unlock").checked;
if (!input("remember-unlock").checked) input("saved-unlock-password").value = "";
}
input("remember-unlock").onchange = savedUnlockChanged;
function hideUnlockToast() {
clearTimeout(unlockToastTimer);
unlockToastTimer = undefined;
element("unlock-toast").hidden = true;
}
function openUnlockDialog() {
const dialog = element<HTMLDialogElement>("unlock-dialog");
if (status.unlock?.state !== "awaiting_password" || dialog.open) return;
hideUnlockToast();
dialog.showModal();
}
element("unlock-open").onclick = openUnlockDialog;
element("unlock-close").onclick = () => element<HTMLDialogElement>("unlock-dialog").close();
element<HTMLDialogElement>("unlock-dialog").onclose = () => {
if (element<HTMLDialogElement>("unlock-dialog").open) return;
input("unlock-password").value = "";
input("unlock-remember").checked = false;
};
element("unlock-toast-close").onclick = hideUnlockToast;
function renderUnlock(s: Data) {
const request = s.unlock;
const pending = request?.state === "awaiting_password";
const visible = !!request && ["ready", "waiting_frame"].includes(s.state);
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 dialog = element<HTMLDialogElement>("unlock-dialog");
if (changed || !pending || !visible) {
input("unlock-password").value = "";
input("unlock-remember").checked = false;
}
if (changed || finished) unlockError = "";
if (changed || !visible) hideUnlockToast();
if (dialog.open && (changed || !visible || finished)) dialog.close();
unlockRequestId = request?.request_id;
unlockSessionId = s.session_id;
unlockState = request?.state;
const device = savedDevices.find((d) => d.device_alias === s.device_alias);
if (request?.credential_saved && device) device.unlock_credential_provider = "keyring";
const provider = request?.credential_saved ? "keyring" : device?.unlock_credential_provider;
text("session-unlock-credential", provider === "keyring" ? "已保存 · 自动解锁"
: provider === "environment" ? "环境变量 · 自动解锁" : "待配置");
element("session-unlock-row").hidden = !visible;
const states: Record<string, string> = {
preparing: "准备中", awaiting_password: "等待密码", submitting: "提交中",
submitted: "已提交", cancelled: "已取消", failed: "提交失败",
};
text("session-unlock-state", states[request?.state] ?? "—");
element("session-unlock-state").dataset.state = request?.state ?? "";
element("unlock-open").hidden = !visible || !pending;
element<HTMLButtonElement>("unlock-open").disabled = unlockBusy;
element("unlock-form").hidden = !pending;
input("unlock-password").disabled = unlockBusy;
input("unlock-remember").disabled = unlockBusy || !device;
element<HTMLButtonElement>("unlock-submit").disabled = unlockBusy;
element<HTMLButtonElement>("unlock-cancel").disabled = unlockBusy;
text("unlock-status", unlockError || (unlockBusy || request?.state === "submitting"
? "正在提交解锁,请稍候。" : "请确认远端系统密码框已聚焦,然后输入锁屏密码。"));
if (visible && pending && stateChanged) openUnlockDialog();
if (visible && finished) {
text("unlock-toast-title", request.state === "submitted" ? "解锁请求已提交"
: request.state === "cancelled" ? "解锁已取消" : "解锁操作未完成");
let message = request.state === "submitted" ? "Agent 将检查画面并继续任务。" : "";
if (request.credential_saved) message = "锁屏密码已保存,Agent 将检查画面并继续任务。";
if (request.error) message = messages[request.error.code] ?? "请检查远端画面后再发起解锁。";
if (request.persistence_error) message = "密码保存失败,可通过本机 setup 重新配置。";
text("unlock-toast-message", message);
element("unlock-toast-message").hidden = !message;
element("unlock-toast").dataset.state = request.state;
if (stateChanged && !sessionChanged) {
element("unlock-toast").hidden = false;
clearTimeout(unlockToastTimer);
unlockToastTimer = setTimeout(hideUnlockToast, 6000);
}
}
}
element<HTMLFormElement>("unlock-form").onsubmit = async (event) => {
event.preventDefault();
if (unlockBusy || !unlockRequestId) return;
const args: Data = { request_id: unlockRequestId, password: input("unlock-password").value,
remember_password: input("unlock-remember").checked };
input("unlock-password").value = "";
unlockError = "";
unlockBusy = true;
renderUnlock(status);
try {
const result = await invoke("unlock", args);
renderStatus({ ...status, unlock: result.unlock });
await devices();
} catch (error) {
unlockError = messages[error instanceof Error ? error.message : ""] ?? "解锁请求暂时无法提交,请检查连接状态。";
}
finally { args.password = undefined; unlockBusy = false; renderUnlock(status); }
};
element("unlock-cancel").onclick = async () => {
if (!unlockRequestId || unlockBusy) return;
input("unlock-password").value = "";
try {
const result = await invoke("unlock", { request_id: unlockRequestId, cancel: true });
renderStatus({ ...status, unlock: result.unlock });
} catch (error) {
unlockError = messages[error instanceof Error ? error.message : ""] ?? "取消请求暂时无法提交,请检查连接状态。";
renderUnlock(status);
}
};
function renderStatus(s: Data) {
const connected = s.state === "ready" || s.state === "waiting_frame";
@@ -234,6 +371,7 @@ function renderStatus(s: Data) {
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
const changedSession = s.session_id !== status.session_id;
status = s;
renderUnlock(s);
const connecting = busy || s.state === "waiting_frame";
root.dataset.connected = String(connected);
root.dataset.connecting = String(connecting);
@@ -296,6 +434,7 @@ async function connect() {
display_index: Number(input("display").value),
save_device: input("save").checked,
remember_password: input("remember").checked,
remember_unlock_password: input("remember-unlock").checked,
persistent_authorization: input("authorize").checked,
...(saved
? { device_alias: saved }
@@ -308,7 +447,9 @@ async function connect() {
};
if (!saved || input("password").value)
args.password = input("password").value;
if (input("remember-unlock").checked) args.unlock_password = input("saved-unlock-password").value;
input("password").value = "";
input("saved-unlock-password").value = "";
hidePassword();
try {
const result = await invoke("connect", args);
@@ -472,6 +613,10 @@ document.addEventListener("visibilitychange", () => {
else void poll();
});
window.addEventListener("pagehide", () => {
element<HTMLDialogElement>("unlock-dialog").close();
hideUnlockToast();
input("unlock-password").value = "";
input("saved-unlock-password").value = "";
void closePreview();
});
setInterval(() => {
@@ -505,6 +650,7 @@ async function start() {
connect: "crossdesk_ui_connect",
video_open: "crossdesk_video_open",
control: "crossdesk_viewer_control",
unlock: "crossdesk_ui_unlock",
};
let connected = false,
initial: Data | undefined;