From 2aaf0447a62c336f3a488d77516edbf05ff1d742 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 7 May 2026 11:09:16 +0000 Subject: [PATCH] fix: split provider ssh tty modes --- docs/reference/cli.md | 4 ++-- docs/reference/provider-gateway.md | 2 +- scripts/src/ssh.ts | 2 ++ src/components/backend-core/src/index.ts | 3 ++- src/components/provider-gateway/package.json | 2 +- .../scripts/host-ssh-shell.sh | 6 ++++- src/components/provider-gateway/src/index.ts | 23 +++++++++++-------- src/components/shared/src/index.ts | 1 + 8 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a25af4a7..3b73a466 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -39,7 +39,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 ## SSH Command -`ssh [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出。CLI 会在宿主机启动一个 `docker exec -i unidesk-backend-core bun -e ...` broker,broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`,core 再把 stdin/stdout/stderr 流量通过目标 provider 的既有 WebSocket 转发到 provider-gateway,provider-gateway 最终执行维护用 `ssh -tt` 连接宿主或 WSL sshd。该入口不新增 core 公网端口,不暴露 database,也不改变 frontend/provider ingress 之外的公网边界。 +`ssh [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出。CLI 会在宿主机启动一个 `docker exec -i unidesk-backend-core bun -e ...` broker,broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`,core 再把 stdin/stdout/stderr 流量通过目标 provider 的既有 WebSocket 转发到 provider-gateway,provider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T`;脚本 stdin、`apply-patch` 和 `py` 这类命令模式不得被伪终端回显或注入控制字符。该入口不新增 core 公网端口,不暴露 database,也不改变 frontend/provider ingress 之外的公网边界。 `bun scripts/cli.ts ssh D518` 应表现为登录 D518 WSL 的 shell;`bun scripts/cli.ts ssh D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p`、`-i`、`-l`、`-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。 @@ -68,7 +68,7 @@ PATCH printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--bar=baz' ``` -`ssh py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见且不被 `ssh -tt` 误触发交互 REPL,helper 固定采用“临时文件 + `python3 -u`”模式。 +`ssh py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。 `ssh find` 是常用远端搜索的结构化入口,避免在 Host SSH / WSL SSH 透传里手写 `find \( ... \)`、`*`、管道和多层引号。它会把路径、谓词和 pattern 作为 argv 安全拼接,并支持重复 `--name`、`--iname`、`--path` 或 `--ipath`,重复 pattern 默认按 OR 组合。稳定参数包括 `--max-depth`/`-maxdepth`、`--min-depth`/`-mindepth`、`--type`/`-type`、`--contains`、`--icontains`、`--name`/`-name`、`--iname`/`-iname`、`--path`/`-path`、`--ipath`/`-ipath`、`--mtime`/`-mtime`、`--mmin`/`-mmin`、`--size`/`-size`、`--sort` 和 `--limit N`。典型用法: diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index d3c39f39..59a0531c 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -122,7 +122,7 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi ## Host SSH Maintenance Bridge -宿主 SSH / WSL SSH 转发是 provider-gateway 部署的必备维护能力,但只作为应急维护辅助路径,不用于自动计算任务调度。实现参考 `../web-terminal` 的经验:容器内使用只读挂载的私钥,主动连接宿主或 WSL sshd,并设置 `BatchMode=yes`、`StrictHostKeyChecking=accept-new`、`ServerAliveInterval=20` 和 `ServerAliveCountMax=3`。主 server Compose 会把 `config.json` 的 `sshForwarding.keyDir` 只读挂载为 `/run/host-ssh`,provider 标签会上报 `hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget`,便于在前端节点清单确认维护桥是否具备条件。 +宿主 SSH / WSL SSH 转发是 provider-gateway 部署的必备维护能力,但只作为应急维护辅助路径,不用于自动计算任务调度。实现参考 `../web-terminal` 的经验:容器内使用只读挂载的私钥,主动连接宿主或 WSL sshd,并设置 `BatchMode=yes`、`StrictHostKeyChecking=accept-new`、`ServerAliveInterval=20` 和 `ServerAliveCountMax=3`。TTY 策略必须按会话类型区分:无远端命令的交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T`,避免脚本 stdin 被伪终端回显或把 `Ctrl-D` 当成业务输入。主 server Compose 会把 `config.json` 的 `sshForwarding.keyDir` 只读挂载为 `/run/host-ssh`,provider 标签会上报 `hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget`,便于在前端节点清单确认维护桥是否具备条件。 WSL 计算节点使用 Docker Desktop daemon 时,provider-gateway 容器通常应连接 `host.docker.internal:22`,目标是当前 WSL 发行版里的 sshd,而不是给节点开放公网 SSH。节点侧必须确认 sshd 监听 `22`、目标用户可用维护公钥免密登录、`authorized_keys` 与挂载到 `/run/host-ssh/id_ed25519` 的私钥匹配;如果容器内直连 `host.docker.internal` 都失败,先修复 WSL sshd、Docker Desktop host gateway 或密钥权限,再排查 UniDesk WebSocket 透传。 diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index e4836570..a55fe6bc 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -457,6 +457,7 @@ ws.addEventListener("open", () => { providerId: open.providerId, command: open.command || undefined, cwd: open.cwd || undefined, + tty: open.tty === true, cols: open.cols || 100, rows: open.rows || 30, }); @@ -533,6 +534,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const payload = { providerId, command: wrapRemoteCommand(parsed.remoteCommand), + tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, openTimeoutMs, cols: size.cols, diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index ee46bece..dea5508a 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -1097,7 +1097,7 @@ function numberFromUnknown(value: unknown, fallback: number, min: number, max: n async function handleSshClientMessage(ws: ProviderSocket, raw: string | Buffer): Promise { const text = typeof raw === "string" ? raw : raw.toString("utf8"); - const message = JSON.parse(text) as { type?: unknown; providerId?: unknown; cwd?: unknown; command?: unknown; data?: unknown; encoding?: unknown; cols?: unknown; rows?: unknown }; + const message = JSON.parse(text) as { type?: unknown; providerId?: unknown; cwd?: unknown; command?: unknown; tty?: unknown; data?: unknown; encoding?: unknown; cols?: unknown; rows?: unknown }; if (message.type === "ssh.open") { const providerId = typeof message.providerId === "string" ? message.providerId : ""; if (providerId.length === 0) { @@ -1126,6 +1126,7 @@ async function handleSshClientMessage(ws: ProviderSocket, raw: string | Buffer): }; if (typeof message.cwd === "string" && message.cwd.length > 0) openMessage.cwd = message.cwd; if (typeof message.command === "string" && message.command.length > 0) openMessage.command = message.command; + if (typeof message.tty === "boolean") openMessage.tty = message.tty; provider.send(JSON.stringify(openMessage)); wsSendJson(ws, { type: "ssh.dispatched", providerId, sessionId }); logger("info", "ssh_session_dispatched", { providerId, sessionId, hasCommand: typeof openMessage.command === "string" }); diff --git a/src/components/provider-gateway/package.json b/src/components/provider-gateway/package.json index 0e39f24c..c52e1e08 100644 --- a/src/components/provider-gateway/package.json +++ b/src/components/provider-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@unidesk/provider-gateway", - "version": "0.2.7", + "version": "0.2.8", "private": true, "type": "module", "scripts": { diff --git a/src/components/provider-gateway/scripts/host-ssh-shell.sh b/src/components/provider-gateway/scripts/host-ssh-shell.sh index e7508229..3a12d410 100755 --- a/src/components/provider-gateway/scripts/host-ssh-shell.sh +++ b/src/components/provider-gateway/scripts/host-ssh-shell.sh @@ -17,8 +17,12 @@ q_requested=$(quote "$requested_cwd") q_fallback=$(quote "$fallback_cwd") q_shell=$(quote "$login_shell") remote_cmd="cd $q_requested 2>/dev/null || cd $q_fallback 2>/dev/null || cd; export UNIDESK_BRIDGE=host-ssh; exec $q_shell -l" +tty_arg="-T" +if [ -t 0 ] && [ -t 1 ]; then + tty_arg="-tt" +fi -exec ssh -tt \ +exec ssh "$tty_arg" \ -i "$key" \ -p "$port" \ -o BatchMode=yes \ diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index 11e85086..2fcc9cf3 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -69,6 +69,7 @@ let upgradeSleepTimer: ReturnType | null = null; interface HostSshSession { proc: ReturnType; openedAt: number; + tty: boolean; } interface HostSshStdin { @@ -963,12 +964,12 @@ async function runHostSsh(payload: Record): Promise 0) assertHostSshCommandAllowed(command, requestedCwd); const loginShell = config.hostLoginShell ?? "/bin/bash"; - const resize = Number.isFinite(cols) && Number.isFinite(rows) + const resize = allocateTty && Number.isFinite(cols) && Number.isFinite(rows) ? `stty rows ${Math.max(8, Math.min(120, Math.floor(rows ?? 30)))} cols ${Math.max(20, Math.min(300, Math.floor(cols ?? 100)))} 2>/dev/null || true` : "true"; const enterCwd = `cd ${shellQuote(requestedCwd)} 2>/dev/null || cd ${shellQuote(fallbackCwd)} 2>/dev/null || cd`; @@ -979,7 +980,7 @@ function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: return `${enterCwd}; ${exports}; ${resize}; ${execPart}`; } -function hostSshArgs(remoteScript: string): string[] { +function hostSshArgs(remoteScript: string, allocateTty: boolean): string[] { if (!isHostSshConfigured()) { throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`); } @@ -988,7 +989,7 @@ function hostSshArgs(remoteScript: string): string[] { throw new Error(`host SSH key is not mounted at ${key}`); } return [ - "-tt", + allocateTty ? "-tt" : "-T", "-i", key, "-p", @@ -1044,13 +1045,15 @@ function startHostSshSession(message: CoreHostSshOpenMessage): void { return; } try { - const remoteScript = hostSshRemoteScript(message.command ?? null, message.cwd ?? null, message.cols, message.rows); - const proc = Bun.spawn(["ssh", ...hostSshArgs(remoteScript)], { + const command = typeof message.command === "string" && message.command.length > 0 ? message.command : null; + const allocateTty = typeof message.tty === "boolean" ? message.tty : command === null; + const remoteScript = hostSshRemoteScript(command, message.cwd ?? null, allocateTty, message.cols, message.rows); + const proc = Bun.spawn(["ssh", ...hostSshArgs(remoteScript, allocateTty)], { stdin: "pipe", stdout: "pipe", stderr: "pipe", }); - hostSshSessions.set(message.sessionId, { proc, openedAt: Date.now() }); + hostSshSessions.set(message.sessionId, { proc, openedAt: Date.now(), tty: allocateTty }); sendJson({ type: "host_ssh_opened", providerId: config.providerId, @@ -1075,7 +1078,7 @@ function startHostSshSession(message: CoreHostSshOpenMessage): void { hostSshSessions.delete(message.sessionId); sendHostSshError(message.sessionId, error); }); - logger("info", "host_ssh_session_started", { sessionId: message.sessionId, hasCommand: typeof message.command === "string", cwd: message.cwd ?? null }); + logger("info", "host_ssh_session_started", { sessionId: message.sessionId, hasCommand: command !== null, tty: allocateTty, cwd: message.cwd ?? null }); } catch (error) { sendHostSshError(message.sessionId, error); } @@ -1090,7 +1093,9 @@ function writeHostSshInput(message: CoreHostSshInputMessage): void { try { const stdin = session.proc.stdin as HostSshStdin | undefined; if (stdin === undefined) throw new Error("host SSH stdin is not available"); - stdin.write(Buffer.from(message.data, "base64")); + const chunk = Buffer.from(message.data, "base64"); + if (!session.tty && chunk.length === 1 && chunk[0] === 4) return; + stdin.write(chunk); } catch (error) { sendHostSshError(message.sessionId, error); } diff --git a/src/components/shared/src/index.ts b/src/components/shared/src/index.ts index 58f5f8f7..a55752ef 100644 --- a/src/components/shared/src/index.ts +++ b/src/components/shared/src/index.ts @@ -143,6 +143,7 @@ export interface CoreHostSshOpenMessage { sessionId: string; cwd?: string; command?: string; + tty?: boolean; cols?: number; rows?: number; }