diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 961af0fa..2953d0f0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -19,7 +19,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `server cleanup plan [--min-age-hours N] [--limit N]` 只生成主 server Docker 镜像清理 dry-run 计划,不执行删除;默认 `--min-age-hours 24`,避免把刚发布或刚验证的镜像列为 stale。输出必须包含 `dryRun=true`、`mutation=false`、`policy.deletionExecuted=false`、active containers/images、受保护镜像、candidate stale images、估算释放空间、风险等级、`commandsToReview` 和人工审批清单。计划必须保守白名单:保留 running containers 使用的 image ID,保留 stopped containers 引用的 image ID 直到人工先复核容器,保留 `deploy.json`/`CI.json` 当前 commit-pinned artifact、Compose stable image、上游 digest pin 和 provider-gateway runner image;`protectedStorage` 必须显式列出 PostgreSQL named volume、Baidu Netdisk `.state`、D601 registry storage 和 Docker volumes/host data policy。该入口禁止生成或执行 `docker system prune`、`docker image prune`、`docker builder prune`、`docker volume rm`、`docker compose down -v`、数据库清理或 host data `rm` 命令;未来若增加真实删除,必须另设显式审批参数并先复核 dry-run 输出。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理,Rust backend-core 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`ssh argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `ssh [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `G14:k3s`、`D601:k3s` 或 `G14:k3s::`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。需要在 pod 内改文件时优先使用 `:k3s:: apply-patch`,CLI 会临时注入 pod 内 `apply_patch` helper 并把 patch stdin 交给它。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 +- `ssh [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `G14:k3s`、`D601:k3s` 或 `G14:k3s::`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。`script` 需要传递带短横线的短命令 argv 时可以使用命令本地分隔符 `script -- [args...]`,例如 `tran D601:/path script -- sed -n '1,20p' file`;这个直接命令形态不等待 stdin,顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要在 pod 内改文件时优先使用 `:k3s:: apply-patch`,CLI 会临时注入 pod 内 `apply_patch` helper 并把 patch stdin 交给它。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 - `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 1d8c4ebd..47ab1731 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -170,6 +170,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.", + "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.", "script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 501e3b90..e78ec0f3 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -161,8 +161,12 @@ export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions { for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] ?? ""; if (arg === "--") { - rest.push(...argv.slice(index + 1)); - break; + 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); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 4c0e5d7c..16e1e921 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -806,7 +806,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; } if (subcommand === "script" || subcommand === "sh") { - return { remoteCommand: buildShellStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; + return buildShellCommand(args.slice(1)); } if (subcommand === "argv" || subcommand === "exec") { const toolArgs = args.slice(1); @@ -1446,10 +1446,12 @@ function k3sScriptShell(value: string, option: string): string { return value; } -function buildShellStdinCommand(args: string[]): string { +function buildShellCommand(args: string[]): ParsedSshArgs { let shell = "sh"; const scriptArgs: string[] = []; let afterDoubleDash = false; + let directArgvMode = false; + let shellOptionSeen = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; if (afterDoubleDash) { @@ -1457,18 +1459,28 @@ function buildShellStdinCommand(args: string[]): string { continue; } if (arg === "--") { + if (!shellOptionSeen && scriptArgs.length === 0) { + directArgvMode = true; + scriptArgs.push(...args.slice(index + 1)); + break; + } afterDoubleDash = true; continue; } if (arg === "--shell") { shell = k3sScriptShell(k3sOptionValue(args, index, "ssh script --shell"), "ssh script --shell"); + shellOptionSeen = true; index += 1; continue; } if (arg.startsWith("-")) throw new Error(`unsupported ssh script option: ${arg}`); scriptArgs.push(arg); } - return shellArgv([shell, "-s", "--", ...scriptArgs]); + if (directArgvMode) { + if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command"); + return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" }; + } + return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" }; } function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } { diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 7273e4ff..68f926aa 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { sshHelp } from "./src/help"; import { providerTriageRecommendedCrossChecks } from "./src/provider-triage"; -import { remoteSshFrontendPlanForTest } from "./src/remote"; +import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote"; import { formatSshFailureHint, formatSshRuntimeTimingHint, parseSshArgs, parseSshInvocation, remoteApplyPatchSource, sshFailureHint, sshRuntimeTimingHint } from "./src/ssh"; type JsonRecord = Record; @@ -77,6 +77,11 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script); assertCondition(script.requiresStdin === true, "script helper must require stdin", script); + const directScriptCommand = parseSshArgs(["script", "--", "sed", "-n", "1,2p", "file.txt"]); + assertCondition(directScriptCommand.invocationKind === "argv", "script -- command form must run as direct argv without stdin", directScriptCommand); + 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 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); @@ -116,6 +121,16 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(routeControlScript.parsed.requiresStdin === true, "k3s control-plane script operation must stream local stdin", routeControlScript); assertCondition(routeControlScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'bash' '-s' '--' 'arg'", "D601:k3s script must inject native kubeconfig without manual export", routeControlScript); + const topLevelScriptSeparator = extractRemoteCliOptions(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]); + assertCondition(JSON.stringify(topLevelScriptSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "top-level remote option parser must preserve command-local -- after the command starts", topLevelScriptSeparator); + const topLevelScriptInvocation = parseSshInvocation(topLevelScriptSeparator.args[1] as string, topLevelScriptSeparator.args.slice(2)); + assertCondition(topLevelScriptInvocation.parsed.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- must allow argv such as sed -n to execute without being parsed as script options", topLevelScriptInvocation); + assertCondition(topLevelScriptInvocation.parsed.requiresStdin === false, "script -- direct command must not require stdin after top-level parsing", topLevelScriptInvocation); + + const remoteOptionSeparator = extractRemoteCliOptions(["--main-server-ip", "74.48.78.17", "--", "ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]); + assertCondition(remoteOptionSeparator.host === "74.48.78.17", "global remote options before -- must still be parsed", remoteOptionSeparator); + assertCondition(JSON.stringify(remoteOptionSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "global -- must not strip nested command separators", remoteOptionSeparator); + const routeApplyPatch = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["apply-patch"]); assertCondition(routeApplyPatch.parsed.requiresStdin === true, "k3s apply-patch operation must stream local patch stdin", routeApplyPatch); assertCondition(routeApplyPatch.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-s' '--'", "D601:k3s:: apply-patch must enter pod with stdin", routeApplyPatch); @@ -357,12 +372,14 @@ export function runSshArgvGuidanceContract(): JsonRecord { checks: [ "argv form is classified and quoted as the success path for non-interactive commands", "stdin script form removes shell-command strings for host and k3s workload scripts", + "script -- command form executes dash-prefixed argv without waiting for stdin", "pod apply-patch operation injects helper and forwards patch stdin", "pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping", "apply-patch uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit", "legacy operation-in-route forms are rejected in any k3s route segment with canonical route-plus-operation guidance", "post-provider k3s shorthand is rejected so location and operation stay separated", "k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv", + "top-level remote option parsing preserves command-local -- separators for script -- sed -n style commands", "ssh-like timeout/kex failures emit one structured argv retry hint", "ssh runtime emits one structured timing hint on stderr and marks operations over 10 seconds as warnings", "help text documents stdin script passthrough and UNIDESK_SSH_HINT",