fix: isolate trans ssh bootstrap

This commit is contained in:
Codex
2026-06-19 15:43:54 +00:00
parent 1c371c7431
commit f9e11ff93e
8 changed files with 1152 additions and 13 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ description: UniDesk SSH 透传与 apply-patch 语法 — `trans <route> <operat
UniDesk 分布式 SSH 透传入口,统一通过 `trans <route> <operation>` 在远端 host/k3s/Windows 上执行命令或文本 patch。
**固定入口**: `trans <route> ...`wrapper 位于 `/root/.local/bin/trans`等价 `bun scripts/cli.ts ssh "$@"`
**固定入口**: `trans <route> ...`wrapper 位于 `/root/.local/bin/trans`委托 repo 内 ssh-only 启动入口 `bun scripts/ssh-cli.ts ssh "$@"`,避免被无关 CLI 子命令模块解析失败拖垮
---
+4 -4
View File
@@ -1,8 +1,8 @@
# UniDesk CLI Reference
UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式固定为 `bun scripts/cli.ts <command>`;普通根 CLI 子命令仍使用该入口。`trans <route> ...``bun scripts/cli.ts ssh <route> ...` 的短 alias,只用于 SSH/WSL/k3s 透传,用于避免远端操作里反复输出过长前缀;长期参考文档、AGENTS 索引、CLI help 和人工远端操作示例都必须优先写 `trans ...`,不得再把 `bun scripts/cli.ts ssh ...` 作为默认透传入口。CLI 默认输出 JSON,所有成功和失败路径都必须向 stdout 写出结构化对象,避免无输出造成状态不可观测。
UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式固定为 `bun scripts/cli.ts <command>`;普通根 CLI 子命令仍使用该入口。`trans <route> ...`SSH/WSL/k3s 透传专用入口,wrapper 委托轻量 `scripts/ssh-cli.ts` 启动链路,避免被无关根 CLI 子命令模块的解析或语法错误拖垮;长期参考文档、AGENTS 索引、CLI help 和人工远端操作示例都必须优先写 `trans ...`,不得再把 `bun scripts/cli.ts ssh ...` 作为默认透传入口。CLI 默认输出 JSON,所有成功和失败路径都必须向 stdout 写出结构化对象,避免无输出造成状态不可观测。
主 server 必须在 PATH 上提供 `/root/.local/bin/trans` 可执行 wrapper,内容委托 repo 内版本化 `scripts/trans` 并执行 `bun scripts/cli.ts ssh "$@"`;交互 shell 可额外提供 alias,但非交互 Codex `exec` 和脚本不能依赖 alias 展开。
主 server 必须在 PATH 上提供 `/root/.local/bin/trans` 可执行 wrapper,内容委托 repo 内版本化 `scripts/trans` 并执行 `bun scripts/ssh-cli.ts ssh "$@"`;交互 shell 可额外提供 alias,但非交互 Codex `exec` 和脚本不能依赖 alias 展开。
`trans` wrapper 是 SSH/WSL/k3s 透传的唯一默认入口:人工/Codex 远端操作、长期参考文档、AGENTS 索引、CLI help、非交互脚本和非交互 `exec` 都必须直接调主 server PATH 上的 `/root/.local/bin/trans`;禁止把 `bun scripts/cli.ts ssh ...``bun scripts/cli.ts trans ...` 或任何带 `bun scripts/cli.ts` 前缀的透传写法作为默认入口。`bun scripts/cli.ts help``config``server``provider``microservice` 等普通根 CLI 子命令不受这条限制,仍使用 `bun scripts/cli.ts <command>`,避免透传命令和根子命令在调用前缀上互相混淆。
@@ -233,11 +233,11 @@ GitHub issue/PR 正文局部修补必须优先使用 `trans gh:/owner/repo/issue
## SSH Command
`trans <providerId> [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出,底层等价于 `bun scripts/cli.ts ssh <providerId> ...`。CLI 会在宿主机启动 `docker exec -i unidesk-backend-core backend-core --ssh-broker ...`broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`core 使用 provider WebSocket 下发 open/dispatch 控制消息,但 stdin/stdout/stderr 数据面必须走 provider 主动连接 main server 的 `host.ssh.tcp-pool` TCP warm poolprovider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T``apply-patch`、脚本 stdin、`py` 和旧 `apply-patch-v1` fallback 这类命令模式不得被伪终端回显或注入控制字符。该入口不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的业务边界;provider data TCP port 是 provider 主动连入的数据面端口,不是计算节点入站要求。
`trans <providerId> [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出,默认由 `scripts/ssh-cli.ts` 只加载 SSH/route/远程前端转发相关模块。主 server 本地执行时会在宿主机启动 `docker exec -i unidesk-backend-core backend-core --ssh-broker ...`broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`core 使用 provider WebSocket 下发 open/dispatch 控制消息,但 stdin/stdout/stderr 数据面必须走 provider 主动连接 main server 的 `host.ssh.tcp-pool` TCP warm poolprovider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T``apply-patch`、脚本 stdin、`py` 和旧 `apply-patch-v1` fallback 这类命令模式不得被伪终端回显或注入控制字符。该入口不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的业务边界;provider data TCP port 是 provider 主动连入的数据面端口,不是计算节点入站要求。
`trans --help``trans <providerId> --help` 是本地 JSON 帮助命令,必须快速返回;不能把 `--help` 解析成 Provider ID,不能打开交互 shell,也不能等待 provider 会话。
主 server 固定提供 `trans` 缩写,等价于 `bun scripts/cli.ts ssh "$@"` 的受控 UniDesk SSH 透传入口。这里必须同时保留两层入口:交互式 shell 可额外配置 aliasCodex `exec`、脚本和其他非交互 shell 不会自动展开 alias,所以还必须有 `/root/.local/bin/trans` 可执行 wrapper,内容固定为委托 repo 内版本化脚本:
主 server 固定提供 `trans` 缩写,等价于 `bun scripts/ssh-cli.ts ssh "$@"` 的受控 UniDesk SSH 透传入口。这里必须同时保留两层入口:交互式 shell 可额外配置 aliasCodex `exec`、脚本和其他非交互 shell 不会自动展开 alias,所以还必须有 `/root/.local/bin/trans` 可执行 wrapper,内容固定为委托 repo 内版本化脚本:
```sh
#!/bin/sh
+91
View File
@@ -0,0 +1,91 @@
export interface RemoteCliOptions {
host: string | null;
user: string;
port: number;
projectRoot: string;
identityFile: string | null;
transport: "auto" | "frontend" | "ssh";
args: string[];
}
const hostOptions = new Set(["--main-server-ip", "--main-server", "--server"]);
const userOptions = new Set(["--main-server-user", "--server-user"]);
const portOptions = new Set(["--main-server-port", "--server-port"]);
const rootOptions = new Set(["--main-server-root", "--server-root"]);
const keyOptions = new Set(["--main-server-key", "--server-key"]);
const transportOptions = new Set(["--main-server-transport", "--server-transport"]);
function positivePort(raw: string, option: string): number {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0 || value > 65535) throw new Error(`${option} must be a TCP port from 1 to 65535`);
return value;
}
function requiredValue(argv: string[], index: number, option: string): string {
const value = argv[index + 1];
if (value === undefined || value.length === 0) throw new Error(`${option} requires a non-empty value`);
return value;
}
function transportValue(raw: string, option: string): RemoteCliOptions["transport"] {
if (raw === "auto" || raw === "frontend" || raw === "ssh") return raw;
throw new Error(`${option} must be one of: auto, frontend, ssh`);
}
export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions {
const rest: string[] = [];
const options: RemoteCliOptions = {
host: null,
user: "root",
port: 22,
projectRoot: "/root/unidesk",
identityFile: null,
transport: "auto",
args: rest,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (arg === "--") {
if (rest.length === 0) {
rest.push(...argv.slice(index + 1));
break;
}
rest.push(arg);
continue;
}
if (hostOptions.has(arg)) {
options.host = requiredValue(argv, index, arg);
index += 1;
continue;
}
if (userOptions.has(arg)) {
options.user = requiredValue(argv, index, arg);
index += 1;
continue;
}
if (portOptions.has(arg)) {
options.port = positivePort(requiredValue(argv, index, arg), arg);
index += 1;
continue;
}
if (rootOptions.has(arg)) {
options.projectRoot = requiredValue(argv, index, arg);
index += 1;
continue;
}
if (keyOptions.has(arg)) {
options.identityFile = requiredValue(argv, index, arg);
index += 1;
continue;
}
if (transportOptions.has(arg)) {
options.transport = transportValue(requiredValue(argv, index, arg), arg);
index += 1;
continue;
}
rest.push(arg);
}
return options;
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -3180,8 +3180,8 @@ async function runSshStreamRemoteCommand(
}
async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise<number> {
const { runRemoteCli } = await import("./remote");
return await runRemoteCli({
const { runRemoteSshCli } = await import("./remote-ssh");
return await runRemoteSshCli({
host,
user: "root",
port: 22,
@@ -3212,7 +3212,7 @@ export async function runSshCommandCapture(config: UniDeskConfig, target: string
}
async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
const { runRemoteSshCommandCapture } = await import("./remote");
const { runRemoteSshCommandCapture } = await import("./remote-ssh");
return await runRemoteSshCommandCapture(config, host, target, args, input);
}
+47
View File
@@ -0,0 +1,47 @@
import { readConfig } from "./src/config";
import { isHelpToken, sshHelp } from "./src/help";
import { emitError, emitJson } from "./src/output";
import { extractRemoteCliOptions } from "./src/remote-options";
import { runRemoteSshCli } from "./src/remote-ssh";
import { runSsh } from "./src/ssh";
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
const args = normalizeSshCommandArgs(remoteOptions.args);
const commandName = args.join(" ") || "ssh";
function normalizeSshCommandArgs(rawArgs: string[]): string[] {
if (rawArgs[0] === "ssh") return rawArgs;
return ["ssh", ...rawArgs];
}
function isGhContentRouteTarget(target: string | undefined): boolean {
return typeof target === "string" && target.startsWith("gh:");
}
async function main(): Promise<void> {
const [top, sub, third] = args;
if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || "<empty>"}`);
if (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3)) {
emitJson(commandName, sshHelp());
return;
}
if (remoteOptions.host !== null) {
process.exitCode = await runRemoteSshCli({ ...remoteOptions, args }, readConfig());
return;
}
if (isGhContentRouteTarget(sub)) {
const { runGhContentRoute } = await import("./src/gh-route");
process.exitCode = await runGhContentRoute(sub, args.slice(2));
return;
}
process.exitCode = await runSsh(readConfig(), sub, args.slice(2));
}
main().catch((error) => {
emitError(commandName, error);
process.exitCode = 1;
});
+3 -3
View File
@@ -2,7 +2,7 @@
set -eu
repo=${UNIDESK_TRAN_REPO_ROOT:-/root/unidesk}
if [ ! -f "$repo/scripts/cli.ts" ]; then
if [ ! -f "$repo/scripts/ssh-cli.ts" ]; then
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
fi
@@ -49,8 +49,8 @@ if [ -n "${CODE_QUEUE_SERVICE_ROLE:-}" ] || [ -n "${CODE_QUEUE_INSTANCE_ID:-}" ]
fi
if [ "$runner_env" = 1 ] && [ -n "$host" ] && [ "${UNIDESK_TRAN_LOCAL:-}" != "1" ]; then
bun "$repo/scripts/cli.ts" --main-server-ip "$host" ssh "$@"
bun "$repo/scripts/ssh-cli.ts" --main-server-ip "$host" ssh "$@"
exit $?
fi
bun "$repo/scripts/cli.ts" ssh "$@"
bun "$repo/scripts/ssh-cli.ts" ssh "$@"
+2 -2
View File
@@ -2,9 +2,9 @@
set -eu
repo=${UNIDESK_TRANS_REPO_ROOT:-/root/unidesk}
if [ ! -f "$repo/scripts/cli.ts" ]; then
if [ ! -f "$repo/scripts/ssh-cli.ts" ]; then
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
fi
exec bun "$repo/scripts/cli.ts" ssh "$@"
exec bun "$repo/scripts/ssh-cli.ts" ssh "$@"