diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9180beb4..78cd5537 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -123,7 +123,9 @@ exec bun /root/unidesk/scripts/cli.ts ssh "$@" 主 server 上的人工/Codex 分布式敏捷操作必须直接写 `tran ...`,不要在 Codex 工具调用里退回完整 `bun scripts/cli.ts ssh ...` 前缀。例如 `tran D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch`、`tran D601:k3s kubectl get pods -n hwlab-dev` 或 `tran D601:k3s:hwlab-dev:hwlab-cloud-web/tmp pwd`。CLI 命令参考和需要跨机器复制的脚本为了说明稳定入口,可以保留完整 `bun scripts/cli.ts ssh ...` 形式;`tran` 是主 server 本机操作纪律,不作为远端 provider 或 CI/CD 的前置依赖。 -`tran` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `tran G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `tran G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `tran G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 +`tran` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `tran G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `tran G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,使用 `tran G14:/root/hwlab shell 'sed -n "1,20p" a && sed -n "1,20p" b'`,CLI 会把整段字符串放进目标节点的 `sh -c`,第二个 `sed`、管道和重定向都会留在远端。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `tran G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 + +本地 shell 运算符不是 `tran` 可以拦截的内容。`tran G14:/root/hwlab sed -n '1,20p' AGENTS.md && sed -n '1,20p' docs/reference/g14.md` 会先由 master server 的本地 shell 拆成两个命令,只有第一个 `sed` 进入 G14,第二个 `sed` 会在 master server 当前目录执行。需要把两个命令都放到目标节点时,必须写成 `tran G14:/root/hwlab shell 'sed -n "1,20p" AGENTS.md && sed -n "1,20p" docs/reference/g14.md'`,或者用 `tran G14:/root/hwlab script <<'SCRIPT'` 把多行脚本送到远端。 `tran` wrapper 会在打开 provider SSH session 前,对同一个 provider/plane 的非交互调用做本机文件锁串行化。该锁只覆盖 `tran ...` 这类短命令,不覆盖 `tran ` 交互 shell,目的是避免 Codex 并发读文件或并发小命令同时冲击同一个 provider 的 session allocator,导致所有调用在 `provider session` 打开阶段超时。锁目录默认是 `/tmp/unidesk-tran-locks`,可用 `UNIDESK_TRAN_LOCK_DIR` 调整;等待超过 `UNIDESK_TRAN_LOCK_NOTICE_SECONDS` 会在 stderr 提示正在排队,超过 `UNIDESK_TRAN_LOCK_WARNING_SECONDS` 会提示高频分布式调用正在排队,超过 `UNIDESK_TRAN_LOCK_TIMEOUT_SECONDS` 会失败。只有排查锁本身或验证底层并发能力时才允许临时设置 `UNIDESK_TRAN_SESSION_LOCK=0`,普通分布式开发不得绕过该锁。 @@ -160,7 +162,7 @@ printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--b `ssh py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。 -如果远端逻辑需要 shell 特性,不要再把整段脚本作为命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --`,`--shell bash` 可切换为 bash,`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc,复用脚本时才用 `< script.sh` 文件重定向。典型用法: +如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --`,`--shell bash` 可切换为 bash,`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `shell ''`;复用脚本时才用 `< script.sh` 文件重定向。典型用法: ```bash cat <<'SCRIPT' | bun scripts/cli.ts ssh D601 script --shell bash -- alpha @@ -172,6 +174,8 @@ SCRIPT 这个入口的目标是分布式调试的“0 shell-command-string”路径:本地 shell 只负责 heredoc/stdin,UniDesk 只负责 provider 路由,远端 shell 只解释脚本正文。脚本正文里仍然要遵守 shell 语言自身的规则,但不再穿过本地 shell、远端 shell、kubectl exec 和容器 shell 的多重字符串转义。 +`ssh shell ''` 是一行远端 shell 逻辑的逃生阀,不取代 `script`。它的输入必须作为一个 quoted argv 到达 CLI,适合 `sed ... && sed ...`、`kubectl get ... | head` 或一次性环境探测;它仍然只穿过一次目标 shell,不能解决本地 shell 已经拆开的外层 `&&`、`|`、`>`。k3s 控制面同样支持 `ssh G14:k3s shell 'kubectl get nodes && kubectl get pods -A'`,并默认注入 `/etc/rancher/k3s/k3s.yaml`;pod route 支持 `ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app shell 'pwd && ls'`,先进入 pod workspace 再执行一行 shell 逻辑。 + `ssh skills` 是远端 skill 发现入口,也可写作 `ssh skill discover`。输出固定为 JSON,包含 `node`、`roots`、`counts` 和 `skills`:`roots` 会显示每个候选 skill 根目录是否存在、扫描到多少 skill 以及错误;`skills` 会给出 `scope`、`name`、`description`、`path`、`skillMd` 和可转换时的 `windowsPath`。默认扫描远端用户的 `~/.agents/skills`、`~/.codex/skills`、可访问的 `/root/.agents/skills`、`/root/.codex/skills`;如果目标是 WSL,还会扫描 `/mnt/c/Users/*/.agents/skills` 与 `/mnt/c/Users/*/.codex/skills`,从而一次性看清 WSL 和 Windows 两套 skill。常用参数是 `--scope wsl`、`--scope windows`、`--limit N`、`--max-depth N`、`--root ` 和 `--windows-root `;不要用宽泛的 Linux `find /mnt/*` 扫 Windows 盘,优先用这个结构化入口避免卡在 Windows 挂载层。 ```bash diff --git a/scripts/src/help.ts b/scripts/src/help.ts index b84f19bd..6700f79f 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -151,6 +151,7 @@ export function sshHelp(): unknown { "bun scripts/cli.ts ssh apply-patch [--allow-loose] < patch.diff", "bun scripts/cli.ts ssh py [script-args...] < script.py", "bun scripts/cli.ts ssh script [--shell sh|bash] [script-args...] <<'SCRIPT'", + "bun scripts/cli.ts ssh shell [--shell sh|bash] \"sed -n '1,20p' a && sed -n '1,20p' b\"", "bun scripts/cli.ts ssh skills [--scope all|wsl|windows] [--limit N]", "bun scripts/cli.ts ssh find [--contains TEXT] [--limit N]", "bun scripts/cli.ts ssh glob [--root DIR] [--pattern PATTERN]", @@ -170,6 +171,7 @@ export function sshHelp(): unknown { notes: [ "ssh --help and ssh --help print this JSON help and never open an interactive session.", "For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.", + "For one-line remote shell logic without a heredoc, use `shell ''`; outer shell operators written outside tran, such as `tran G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI.", "When a short remote command is easier to type through the script path and needs dash-prefixed argv, write `script -- [args...]`; this direct form does not wait for stdin, and the command-local `--` is preserved by local and remote `tran` parsing, so examples such as `tran D601:/work script -- sed -n '1,20p' file` do not require a heredoc just to pass `-n`.", "For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.", "apply-patch rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 969589ba..35180403 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -808,6 +808,9 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { if (subcommand === "script" || subcommand === "sh") { return buildShellCommand(args.slice(1)); } + if (subcommand === "shell") { + return buildShellStringCommand(args.slice(1)); + } if (subcommand === "argv" || subcommand === "exec") { const toolArgs = args.slice(1); if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`); @@ -1080,6 +1083,10 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P if (operation === "script" || operation === "sh") { return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; } + if (operation === "shell") { + const parsed = parseShellStringOperationArgs(args.slice(1), `ssh ${route.providerId}:k3s shell`); + return { remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" }; + } if (operation === "guard") { if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`); return { remoteCommand: buildK3sGuardCommand(route.providerId), requiresStdin: false, invocationKind: "helper" }; @@ -1100,6 +1107,10 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS const operationArgs = args.slice(1); if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]); if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" }; + if (operation === "shell") { + const parsed = parseShellStringOperationArgs(operationArgs, `ssh ${route.raw} shell`); + return { remoteCommand: buildK3sExecCommand([...targetArgs, "--", parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" }; + } if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" }; if (operation === "argv") return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "argv" }; if (operation === "get" || operation === "describe") { @@ -1300,6 +1311,26 @@ function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string { return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]); } +function shellStringFromArgs(args: string[], commandName = "ssh shell"): string { + if (args.length === 0) throw new Error(`${commandName} requires a command string`); + return args.join(" "); +} + +function parseShellStringOperationArgs(args: string[], commandName: string): { shell: string; command: string } { + let shell = "sh"; + const commandArgs: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === "--shell") { + shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} --shell`), `${commandName} --shell`); + index += 1; + continue; + } + commandArgs.push(arg); + } + return { shell, command: shellStringFromArgs(commandArgs, commandName) }; +} + function buildK3sLogsCommand(args: string[]): string { const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false }); if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace "); @@ -1483,6 +1514,11 @@ function buildShellCommand(args: string[]): ParsedSshArgs { return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" }; } +function buildShellStringCommand(args: string[]): ParsedSshArgs { + const parsed = parseShellStringOperationArgs(args, "ssh shell"); + return { remoteCommand: shellArgv([parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" }; +} + function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } { const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__"; const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__"; diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 66501b69..2bf81f2c 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -133,6 +133,11 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(directScriptCommand.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- command form must preserve dash-prefixed command args", directScriptCommand); assertCondition(directScriptCommand.requiresStdin === false, "script -- command form must not wait for stdin", directScriptCommand); + const shellOneLiner = parseSshArgs(["shell", "sed -n '1,2p' a && sed -n '1,2p' b"]); + assertCondition(shellOneLiner.invocationKind === "helper", "shell one-liner must be a helper operation", shellOneLiner); + assertCondition(shellOneLiner.remoteCommand === "'sh' '-c' 'sed -n '\\''1,2p'\\'' a && sed -n '\\''1,2p'\\'' b'", "shell one-liner must keep command operators inside the remote shell", shellOneLiner); + assertCondition(shellOneLiner.requiresStdin === false, "shell one-liner must not require stdin", shellOneLiner); + const k3sGuard = parseSshInvocation("D601:k3s", ["guard"]).parsed; assertCondition(k3sGuard.invocationKind === "helper", "k3s guard must be classified as helper", k3sGuard); assertCondition(k3sGuard.remoteCommand?.includes("KUBECONFIG") && k3sGuard.remoteCommand.includes("/etc/rancher/k3s/k3s.yaml"), "k3s guard must force native k3s kubeconfig", k3sGuard); @@ -146,6 +151,9 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(routeKubectl.route.plane === "k3s" && routeKubectl.route.entry === null, "route must keep kubectl as an operation, not as a route entry", routeKubectl); assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s kubectl must map to kubectl argv", routeKubectl); + const routeK3sShell = parseSshInvocation("D601:k3s", ["shell", "kubectl get nodes && kubectl get pods -A"]); + assertCondition(routeK3sShell.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'sh' '-c' 'kubectl get nodes && kubectl get pods -A'", "D601:k3s shell must run one-line shell logic on the k3s host with native kubeconfig", routeK3sShell); + const g14Guard = parseSshInvocation("G14:k3s", []); assertCondition(g14Guard.providerId === "G14" && g14Guard.route.plane === "k3s", "G14:k3s must parse as a native k3s route", g14Guard); assertCondition(g14Guard.parsed.remoteCommand?.includes("UNIDESK_K3S_PROVIDER_ID") && g14Guard.parsed.remoteCommand.includes("ubuntu-rog-zephyrus-g14-ga401iv-ga401iv"), "G14:k3s guard must use the G14 node profile", g14Guard); @@ -164,6 +172,9 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(routeTargetArgv.parsed.invocationKind === "argv", "k3s target argv operation must stay explicit argv", routeTargetArgv); assertCondition(routeTargetArgv.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'printf ok'", "D601:k3s:: argv must exec the argv payload instead of treating argv as a pod command", routeTargetArgv); + const routeTargetShell = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["shell", "pwd && ls"]); + assertCondition(routeTargetShell.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-c' 'pwd && ls'", "D601:k3s::/ shell must run shell logic after cd inside the pod", routeTargetShell); + const routeScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--shell", "bash", "--", "arg"]); assertCondition(routeScript.parsed.requiresStdin === true, "k3s script operation must stream local stdin", routeScript); assertCondition(routeScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'bash' '-s' '--' 'arg'", "D601:k3s:: script must map stdin to shell -s", routeScript); @@ -361,6 +372,7 @@ export function runSshArgvGuidanceContract(): JsonRecord { const helpText = JSON.stringify(sshHelp()); assertCondition(helpText.includes("ssh script [--shell sh|bash] [script-args...] <<'SCRIPT'"), "ssh help must recommend stdin script passthrough for shell scripts", helpText); + assertCondition(helpText.includes("ssh shell [--shell sh|bash]") && helpText.includes("outer shell operators written outside tran"), "ssh help must document one-line shell passthrough and the local operator boundary", helpText); assertCondition(helpText.includes("inherits provider proxy variables"), "ssh help must state default script inherits provider proxy env", helpText); assertCondition(helpText.includes("not as a proxy workaround"), "ssh help must reserve --shell bash for bash syntax instead of proxy workarounds", helpText); assertCondition(helpText.includes("ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", helpText);