fix: 收敛受控 CLI 调用与诊断

This commit is contained in:
Codex
2026-07-12 17:08:00 +02:00
parent 00c60a17cd
commit 2a80e08a40
13 changed files with 168 additions and 35 deletions
+4
View File
@@ -20,6 +20,10 @@ bun scripts/cli.ts platform-infra observability search --grep <service> --limit
## P0 边界
- 微服务契约版本、源码/镜像版本和 `commitId` 漂移只能记录为非阻塞 warning;不得建议或实现线上 admission、dispatch、readiness 或用户业务门禁。业务已成功时,漂移告警不能改写成功结论。
- `diagnose-code-agent` 同时展示 AgentRun durable authority 与 HWLAB projection/read model
- 权威终态决定 `effectiveStatus`
- 投影滞后或冲突固定输出 `warning=true``blocking=false`
- `mutation=false` 表示该聚合入口只读,不执行补链或运行面修补。
- 可见性问题优先修复;状态、耗时、失败原因、trace、命令结果或关键证据不可见时,先补 CLI/日志/状态输出。
- 离线调查中的 OTel 只能分析既有 trace 和已有业务记录;如果 trace/search/diagnose 摘要不好用、缺少业务映射或无法区分 observability gap 与业务失败,先改进 OTel CLI/analyze/instrumentation 可见性,再继续业务结论。
- OTel 查询默认低噪声摘要;完整 span/context 显式 `--full`/`--raw`
+4
View File
@@ -67,6 +67,10 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md
- PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8,读取 UTF-8 Markdown 时应与 `cat` helper 保持一致。
- Windows `ps '<PowerShell source>'` 的单个参数按 PowerShell 源码执行;`ps <command> <arg...>` 的多个参数按 argv 边界执行,Windows native application 通过显式命令行引用保留 `python -c` 代码、路径、encoding 和字典 key 中的嵌套引号。
- `trans` / `tran` 是 PATH wrapper,不是 `bun scripts/cli.ts` 的根子命令;误写 `bun scripts/cli.ts trans ...` 时应读取结构化 `trans-root-cli-entrypoint-moved` 错误,并改用 `trans <route> <operation> [args...]`
- cwd 语义按运行面分层:
- host/workspace 将绝对 cwd 写入 route,例如 `trans NC01:/root/unidesk exec git status --short`
- k3s workload 使用 route cwd 后缀或 `exec --cwd /workspace -- <command>`
- host/workspace 误用 `exec|argv|sh|bash --cwd` 会在连接前返回 `trans-host-cwd-belongs-in-route` 与精确替代命令,禁止继续试探远端参数。
- Direct argv 会保留调用端已经形成的每个 argv token,包括空格和中文;需要 shell 语法时仍显式使用 `sh``bash`、Windows `cmd``ps`
- Windows workspace 中依赖 Windows PATH、解释器或工具链的命令优先走 `<provider>:win/<drive>/<path> cmd|ps`;不得因同一 WSL provider 的 host/WSL plane 缺少 `python`、编译器或其他命令,就判断 Windows plane 也未安装。Windows Python 例如 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`
- `/mnt/<drive>` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win/<drive>/... cmd``UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。
+13 -1
View File
@@ -22,7 +22,19 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式
`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>`,避免透传命令和根子命令在调用前缀上互相混淆。
误写 `bun scripts/cli.ts trans ...``bun scripts/cli.ts tran ...` 时,根 CLI 必须返回结构化 `trans-root-cli-entrypoint-moved` 错误、`replacementExamples` `Use: trans <route> <operation> [args...]` 迁移提示,不能退化成通用 `Unknown command`
误写 `bun scripts/cli.ts trans ...``bun scripts/cli.ts tran ...` 时,根 CLI 必须在本地返回紧凑的 `trans-root-cli-entrypoint-moved` 输入错误、精确 `usage` 替代命令`Use: trans <route> <operation> [args...]` 迁移提示,不能连接运行面、披露 stack 或退化成通用 `Unknown command`
- `trans` 的 cwd 与本地校验合同:
- host/workspace 的 cwd 只写入 route,例如 `trans NC01:/root/unidesk exec git status --short``trans NC01:/root/unidesk bash <<'BASH'`
- k3s workload 的容器 cwd 使用 route 后缀或 `exec --cwd /absolute/path -- <command>`
- host/workspace 上误用 `exec|argv|sh|bash --cwd` 时,必须在选择 transport 和连接运行面前返回 `trans-host-cwd-belongs-in-route` 与精确替代命令;
- `trans --help` 必须直接展示 host/workspace 与 k3s cwd 摘要,`trans --help exec|bash` 提供可执行示例。
- Code Agent 跨服务只读诊断合同:
- `platform-infra observability diagnose-code-agent` 是 AgentRun durable authority、HWLAB projection/read model 与 OTel 服务覆盖的单命令聚合入口;
- 默认 text 与显式 `--full|--raw` JSON 必须共同披露 `typed outcome``effectiveStatus``mutation=false`、durable authority 和 projection 状态;
- AgentRun 权威终态与 HWLAB 投影不一致时,以权威终态作为有效结果,并将投影滞后或冲突标记为 `warning=true``blocking=false`
- 聚合入口只读,不得触发 mirror sync、PipelineRun、Argo refresh/sync 或运行面修补。
Windows `ps` 的参数语义分为两种:单个参数是完整 PowerShell source;多个参数是边界明确的 argv。后者对 native application 使用 Windows 命令行引用规则,必须保留 `python -c` 代码中的路径、`encoding="utf-8"`、字典 key 和嵌套引号,并继承 route 对应的 Windows workspace。PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8Windows fs helper 的 `head` / `tail` 同时支持 `-n N``-nN``--lines=N` 和 GNU `-N``wc -l` 必须把选项与路径分开解析。
+5 -13
View File
@@ -769,21 +769,13 @@ async function main(): Promise<void> {
}
if (top === "trans" || top === "tran") {
const error = new Error(`${top} is a PATH wrapper, not a bun scripts/cli.ts subcommand`);
Object.assign(error, {
name: "TransEntrypointMigrationError",
const replacement = `trans ${sub ?? "<route>"} ${args.slice(2).join(" ") || "<operation>"}`;
throw new CliInputError(`${top} is a PATH wrapper, not a bun scripts/cli.ts subcommand`, {
code: "trans-root-cli-entrypoint-moved",
level: "error",
entrypoint: top,
operation: "root-cli-dispatch",
replacementExamples: {
canonical: `trans ${sub ?? "<route>"} ${args.slice(2).join(" ") || "<operation>"}`,
legacyUnified: `bun scripts/cli.ts ssh ${sub ?? "<route>"} ${args.slice(2).join(" ") || "<operation>"}`,
},
migrationHint: "Use: trans <route> <operation> [args...]. The PATH wrapper delegates to the lightweight SSH CLI.",
note: "Ordinary root CLI commands continue to use bun scripts/cli.ts <command>.",
argument: top,
usage: replacement,
hint: "Use trans <route> <operation> [args...]; ordinary root CLI commands continue to use bun scripts/cli.ts <command>.",
});
throw error;
}
throw new CliInputError(`Unknown command: ${commandName}`, {
+15 -4
View File
@@ -190,8 +190,12 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
},
exec: {
group: "process",
description: "在 k3s workload route 中运行进程,并可显式透传 stdin 或指定容器内 cwd。",
usage: ["trans <provider>:k3s:<namespace>:<workload>[:<container>] exec [--cwd /path] [--stdin] -- <command> [args...]"],
description: "运行单个远端进程;host/workspace 的 cwd 写入 routek3s workload 用 --cwd 指定容器内目录。",
usage: [
"trans <provider>:/absolute/workspace exec <command> [args...]",
"trans <provider>:k3s:<namespace>:<workload>[:<container>] exec [--cwd /path] [--stdin] -- <command> [args...]",
],
notes: ["host/workspace 不接受 exec --cwd;改写 route,例如 trans D601:/workspace exec git status。"],
},
git: {
group: "process",
@@ -221,8 +225,9 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
},
bash: {
group: "shell",
description: "仅在需要 pipefail、数组、[[ ]] 等 Bash 语法时执行远端 Bash。",
usage: ["trans <route> bash <<'BASH'", "trans <route> bash -- '<bash command>'"],
description: "仅在需要 pipefail、数组、[[ ]] 等 Bash 语法时执行远端 Bashcwd 由 route 选择。",
usage: ["trans <provider>:/absolute/workspace bash <<'BASH'", "trans <provider>:k3s:<namespace>:<workload>[/cwd] bash <<'BASH'", "trans <route> bash -- '<bash command>'"],
notes: ["host/workspace 不接受 bash --cwd;将绝对目录写入 route。"],
},
py: {
group: "shell",
@@ -327,6 +332,11 @@ export function sshHelp(scope: SshHelpScope | undefined = undefined): unknown {
"远端文本读取优先 cat/rg,修改优先 apply-patchupload/download 仅用于必要的二进制或生成物。",
"普通 trans 短连接上限保持 60s;长流程使用受控 submit-and-poll 入口。",
],
cwdSemantics: {
hostWorkspace: `${entrypoint} <provider>:/absolute/workspace exec|sh|bash ...`,
k3sWorkload: `${entrypoint} <provider>:k3s:<namespace>:<workload>[:<container>] exec --cwd /absolute/container/path -- <command>`,
rule: "host/workspace cwd 属于 routek3s 容器 cwd 可由 route 后缀或 exec --cwd 指定。",
},
};
}
@@ -343,6 +353,7 @@ export function renderSshHelpText(scope: SshHelpScope | undefined = undefined):
`patch: ${SSH_OPERATION_GROUPS.patch.join(" | ")}`,
`transfer: ${SSH_OPERATION_GROUPS.transfer.join(" | ")}`,
`scoped help: ${entrypoint} --help <operation>`,
`cwd: host/workspace uses <provider>:/absolute/workspace; k3s exec uses --cwd /absolute/container/path`,
"boundary: route only selects the target; use sh/bash/ps/cmd explicitly for shell syntax.",
].join("\n");
}
@@ -97,6 +97,7 @@ export function compactDiagnoseCodeAgentResult(value: unknown): Record<string, u
hwlabReadModel: source.hwlabReadModel ?? null,
http: compactDiagnoseHttp(source.http),
projectionLag: source.projectionLag ?? null,
outcome: source.outcome ?? null,
summary: source.summary ?? null,
rootCauseCandidates: asArray(source.rootCauseCandidates).slice(0, 3).map(compactRootCauseCandidate),
spanNameCounts: compactNameCounts(source.spanNameCounts, 6),
@@ -123,16 +123,33 @@ describe("diagnose-code-agent warm-session terminal classification", () => {
expect(first.payload.diagnosticTrace.traceId).not.toBe(second.payload.diagnosticTrace.traceId);
expect(first.payload.diagnosticTrace.traceparent).not.toBe(second.payload.diagnosticTrace.traceparent);
});
test("keeps durable completion authoritative while projection lag stays a nonblocking warning", () => {
const diagnosis = runFixture({ authorityAvailable: true, terminalSpan: false, projectionStale: true });
expect(diagnosis.status).toBe(0);
expect(diagnosis.payload.outcome).toEqual({
typed: "completed-with-projection-warning",
effectiveStatus: "completed",
authority: { kind: "agentrun-durable-command", status: "completed", source: "agentrun-authority" },
projection: { kind: "hwlab-read-model", status: "running", lag: "confirmed", warning: true, blocking: false },
mutation: false,
});
const compact = compactDiagnoseCodeAgentResult(diagnosis.payload);
const rendered = renderDiagnoseCodeAgentTable({ ok: true, target, options, query: {}, result: compact });
expect(rendered.renderedText).toContain("outcome=completed-with-projection-warning effectiveStatus=completed mutation=false");
expect(rendered.renderedText).toContain("durableAuthority kind=agentrun-durable-command status=completed");
expect(rendered.renderedText).toContain("projection kind=hwlab-read-model status=running lag=confirmed warning=true blocking=false");
});
});
function runFixture(input: { authorityAvailable: boolean; terminalSpan: boolean }): {
function runFixture(input: { authorityAvailable: boolean; terminalSpan: boolean; projectionStale?: boolean }): {
status: number | null;
payload: Record<string, any>;
calls: string;
} {
const directory = mkdtempSync(join(tmpdir(), "unidesk-otel-diagnose-"));
temporaryDirectories.push(directory);
writeFileSync(join(directory, "trace.json"), JSON.stringify(traceFixture(input.terminalSpan)));
writeFileSync(join(directory, "trace.json"), JSON.stringify(traceFixture(input.terminalSpan, input.projectionStale === true)));
writeFileSync(join(directory, "command.json"), JSON.stringify({ id: "cmd_second", state: "completed" }));
writeFileSync(join(directory, "result.json"), JSON.stringify({ ok: true, commandId: "cmd_second", status: "completed", commandState: "completed", terminalStatus: "completed" }));
writeFileSync(join(directory, "run-result.json"), JSON.stringify({ ok: true, commandId: "cmd_second", commandState: "completed", terminalStatus: "completed" }));
@@ -235,7 +252,7 @@ function callLines(calls: string): string[] {
return calls.split("\n").map((line) => line.trim()).filter(Boolean);
}
function traceFixture(terminalSpan: boolean): Record<string, unknown> {
function traceFixture(terminalSpan: boolean, projectionStale = false): Record<string, unknown> {
const common = [
attribute("traceId", "trc_warm_second"),
attribute("runId", "run_warm"),
@@ -244,11 +261,17 @@ function traceFixture(terminalSpan: boolean): Record<string, unknown> {
];
return {
batches: [
batch("hwlab-cloud-api", [span("provider_decision", [
...common,
attribute("agent.chat.provider_profile", "gpt.pika"),
attribute("agentRunRunnerNamespace", "agentrun-v02"),
])]),
batch("hwlab-cloud-api", [
span("provider_decision", [
...common,
attribute("agent.chat.provider_profile", "gpt.pika"),
attribute("agentRunRunnerNamespace", "agentrun-v02"),
]),
...(projectionStale ? [
span("trace_events_read", [...common, attribute("sourceEventCount", "6"), attribute("sinceSeq", "7")]),
span("turn_status_read", [...common, attribute("turnStatus", "running")]),
] : []),
]),
batch("agentrun-manager", [
span("command_created", common),
...(terminalSpan ? [span("runner_terminal.completed", [...common, attribute("terminalStatus", "completed"), attribute("eventType", "terminal_status")])] : []),
@@ -2023,10 +2023,10 @@ def root_cause_candidates(http_summary, agentrun, read_model, lag_summary, error
status_conflict = terminal_status_conflicts(agentrun.get("terminalStatus"), read_model_turn_status)
if status_conflict:
candidates.append({
"code": "otel_business_trace_terminal_conflict",
"label": "OTel terminal conflict",
"code": "projection_authority_terminal_conflict",
"label": "projection/authority terminal conflict",
"confidence": 0.86,
"summary": "Scoped AgentRun terminal status conflicts with HWLAB read-model turn status for the requested business trace; prefer the HWLAB read-model status for Workbench RCA and inspect OTel correlation leakage.",
"summary": "HWLAB projection conflicts with the scoped AgentRun durable authority; preserve the AgentRun terminal outcome and inspect projection correlation or lag.",
"evidence": {
"agentrunTerminalStatus": agentrun.get("terminalStatus"),
"hwlabReadModelTurnStatus": read_model_turn_status,
@@ -2502,16 +2502,16 @@ if http_summary.get("actorForbidden"):
read_model_turn_status = read_model.get("turnStatus")
agentrun_read_model_status_conflict = terminal_status_conflicts(terminal_status, read_model_turn_status)
if agentrun_read_model_status_conflict:
facts.append("OTel AgentRun terminal status conflicts with HWLAB read model")
if terminal_status in ("failed", "error", "timeout", "blocked", "cancelled") and not agentrun_read_model_status_conflict:
facts.append("HWLAB projection conflicts with AgentRun durable authority")
if terminal_status in ("failed", "error", "timeout", "blocked", "cancelled"):
failure_kind = agentrun.get("failureKind")
if failure_kind:
facts.append(f"AgentRun terminal failed ({failure_kind})")
else:
facts.append("AgentRun terminal failed")
if terminal_status == "completed" and not agentrun_read_model_status_conflict:
if terminal_status == "completed":
facts.append("AgentRun completed")
if (terminal_status in (None, "") or agentrun_read_model_status_conflict) and read_model_turn_status in ("completed", "failed", "error", "timeout", "blocked", "cancelled", "canceled"):
if terminal_status in (None, "") and read_model_turn_status in ("completed", "failed", "error", "timeout", "blocked", "cancelled", "canceled"):
facts.append("HWLAB read model terminal " + str(read_model_turn_status))
if lag.get("status") in ("confirmed", "suspected"):
facts.append("projection/read-model stale")
@@ -2552,6 +2552,30 @@ evidence = {
"idleWarningSpanCount": len(idle_warning_spans),
"idleWarningSpanTail": [tiny_span(item) for item in idle_warning_spans[-3:]],
}
projection_warning = lag.get("status") in ("confirmed", "suspected") or agentrun_read_model_status_conflict
effective_status = terminal_status if terminal_status not in (None, "") else read_model_turn_status
typed_outcome = "unknown"
if effective_status not in (None, ""):
typed_outcome = str(effective_status)
if projection_warning:
typed_outcome = typed_outcome + "-with-projection-warning"
outcome = {
"typed": typed_outcome,
"effectiveStatus": effective_status,
"authority": {
"kind": "agentrun-durable-command",
"status": terminal_status,
"source": agentrun.get("terminalSource"),
},
"projection": {
"kind": "hwlab-read-model",
"status": read_model_turn_status,
"lag": lag.get("status"),
"warning": projection_warning,
"blocking": False,
},
"mutation": False,
}
payload = {
"ok": trace_rc == 0 and len(spans) > 0,
"mapping": mapping,
@@ -2594,6 +2618,7 @@ payload = {
"actorForbidden": http_summary.get("actorForbidden"),
},
"projectionLag": lag,
"outcome": outcome,
"summary": summary,
"rootCauseCandidates": candidates,
"spanNameCounts": flat["spanNameCounts"][:12],
@@ -761,6 +761,9 @@ export function renderDiagnoseCodeAgentTable(input: {
const observedCommandId = textValue(identity?.commandId);
const observedSessionId = textValue(identity?.sessionId);
const observedRunnerJobId = textValue(identity?.runnerJobId);
const outcome = asPlainRecord(input.result.outcome);
const outcomeAuthority = asPlainRecord(outcome?.authority);
const outcomeProjection = asPlainRecord(outcome?.projection);
const lines = [
`platform-infra observability diagnose-code-agent (${input.ok ? "ok" : "not-ok"})`,
"",
@@ -792,6 +795,9 @@ export function renderDiagnoseCodeAgentTable(input: {
formatTable(["METHOD", "ROUTE", "STATUS", "COUNT"], httpRows.length > 0 ? httpRows : [["-", "-", "-", "-"]]),
"",
"Summary:",
` outcome=${textValue(outcome?.typed)} effectiveStatus=${textValue(outcome?.effectiveStatus)} mutation=${textValue(outcome?.mutation)}`,
` durableAuthority kind=${textValue(outcomeAuthority?.kind)} status=${textValue(outcomeAuthority?.status)} source=${textValue(outcomeAuthority?.source)}`,
` projection kind=${textValue(outcomeProjection?.kind)} status=${textValue(outcomeProjection?.status)} lag=${textValue(outcomeProjection?.lag)} warning=${textValue(outcomeProjection?.warning)} blocking=${textValue(outcomeProjection?.blocking)}`,
` target=${input.target.id} spanCount=${textValue(input.result.spanCount)} servicePath=${joinValues(input.result.servicePath, 60)}`,
` agentrun=${textValue(agentrun?.terminalStatus)} failureKind=${textValue(agentrun?.failureKind)} source=${textValue(agentrun?.terminalSource)} authority=${shortenEnd(JSON.stringify((asPlainRecord(agentrun?.authority) ?? {})), 120)}`,
` authorityQuery namespace=${textValue(agentrunAuthority?.namespace)} source=${textValue(agentrunAuthority?.namespaceSource)} ok=${textValue(agentrunAuthority?.ok)} commandState=${textValue(agentrunAuthority?.commandState)} terminal=${textValue(agentrunAuthority?.terminalStatus)}`,
+19 -1
View File
@@ -86,10 +86,11 @@ describe("ssh bounded progressive help", () => {
test("renders compact top-level and scoped help without dump fallback", () => {
const top = runTransHelp("--help");
expect(Buffer.byteLength(top, "utf8")).toBeLessThan(2_048);
expect(top.trim().split("\n")).toHaveLength(10);
expect(top.trim().split("\n")).toHaveLength(11);
expect(top).toContain("usage: trans <route> <operation>");
expect(top).toContain("transfer: upload | download");
expect(top).toContain("scoped help: trans --help <operation>");
expect(top).toContain("cwd: host/workspace uses <provider>:/absolute/workspace; k3s exec uses --cwd");
expect(top).not.toContain("outputTruncated");
expect(top).not.toContain("dump");
@@ -102,6 +103,10 @@ describe("ssh bounded progressive help", () => {
expect(applyPatch).toContain("usage: trans <route> apply-patch");
expect(applyPatch).not.toContain("outputTruncated");
const exec = runTransHelp("--help", "exec");
expect(exec).toContain("trans <provider>:/absolute/workspace exec <command>");
expect(exec).toContain("k3s:<namespace>:<workload>");
const download = runTransHelp("--help", "download");
expect(download.trim().split("\n").length).toBeLessThanOrEqual(8);
expect(download).toContain("TRANS HELP download");
@@ -159,4 +164,17 @@ describe("ssh bounded progressive help", () => {
expect(result.stdout).not.toContain("stack");
expect(result.stdout).not.toContain(" at ");
});
test("rejects the moved root entrypoint locally with one exact replacement", () => {
const result = spawnSync("bun", ["scripts/cli.ts", "trans", "NC01:/root/unidesk", "git", "status", "--short"], {
cwd: repoRoot,
env: { ...process.env, UNIDESK_TRANS_REPO_ROOT: repoRoot },
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("ERROR cli-input/trans-root-cli-entrypoint-moved");
expect(result.stdout).toContain("usage: trans NC01:/root/unidesk git status --short");
expect(result.stdout).not.toContain("stack");
});
});
+2 -1
View File
@@ -1372,11 +1372,12 @@ function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
const normalizedArgs = normalizeSshOperationArgs(args);
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
// 在选择或连接 transport 前完成解析,保证可预测的 route/operation 错误始终在本地失败。
const invocation = parseSshInvocation(providerId, normalizedArgs);
const plan = sshCaptureBackendPlan(config, process.env);
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs);
}
const invocation = parseSshInvocation(providerId, normalizedArgs);
const parsed = invocation.parsed;
const operationName = normalizedArgs[0] ?? "";
if (isSshFileTransferOperation(normalizedArgs)) {
+14
View File
@@ -214,6 +214,20 @@ describe("ssh windows fs read-only operations", () => {
});
describe("ssh direct argv boundaries and plane diagnostics", () => {
test("rejects host/workspace cwd options locally with an exact route replacement", () => {
try {
parseSshInvocation("NC01:/root/unidesk", ["exec", "--cwd", "/root/hwlab-v03", "--", "git", "status", "--short"]);
throw new Error("expected host cwd input error");
} catch (error) {
expect(error).toMatchObject({
code: "trans-host-cwd-belongs-in-route",
usage: "trans NC01:/root/hwlab-v03 'exec' '--' 'git' 'status' '--short'",
});
}
expect(() => parseSshInvocation("NC01:/root/unidesk", ["bash", "--cwd=/root/hwlab-v03"])).toThrow("cwd belongs in the route");
expect(() => parseSshInvocation("NC01:k3s:hwlab-v03:hwlab-cloud-api", ["exec", "--cwd", "/workspace", "--", "pwd"])).not.toThrow();
});
test("preserves a shell-formed argv token containing spaces and Chinese", () => {
const invocation = parseSshInvocation("G14-WSL:/mnt/d/Work/CONSTAR_workspace", [
"python3",
+22
View File
@@ -3,6 +3,7 @@ import { isSshFileTransferOperation } from "./ssh-file-transfer";
import { isWindowsFsReadOnlyOperation, windowsFsReadOnlyScript } from "./ssh-windows-fs";
import { remoteApplyPatchSource, remoteGlobSource, remoteSkillDiscoverSource } from "./ssh-remote-tools";
import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy";
import { CliInputError } from "./output";
export interface ParsedSshArgs {
remoteCommand: string | null;
@@ -406,10 +407,31 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv
if ((operationArgs[0] ?? "") === "k3s") {
throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${operationArgs.slice(1).join(" ")}`.trim());
}
rejectHostWorkspaceCwdOption(route, operationArgs);
const parsed = parseSshArgs(operationArgs, route.raw);
return { providerId: route.providerId, route, parsed: withTransHostEnvironment(route, parsed) };
}
function rejectHostWorkspaceCwdOption(route: ParsedSshRoute, args: string[]): void {
const operation = args[0] ?? "";
if (!new Set(["exec", "argv", "sh", "bash"]).has(operation)) return;
const cwdIndex = args.findIndex((arg) => arg === "--cwd" || arg === "--workdir" || arg.startsWith("--cwd=") || arg.startsWith("--workdir="));
if (cwdIndex < 0) return;
const option = args[cwdIndex] ?? "--cwd";
const inlineValue = option.includes("=") ? option.slice(option.indexOf("=") + 1) : null;
const cwd = inlineValue ?? args[cwdIndex + 1] ?? "<absolute-workspace>";
const consumed = inlineValue === null ? 2 : 1;
const remaining = [...args.slice(0, cwdIndex), ...args.slice(cwdIndex + consumed)];
const replacementRoute = cwd.startsWith("/") ? `${route.providerId}:${cwd}` : `${route.providerId}:/absolute/workspace`;
const replacement = `trans ${replacementRoute} ${remaining.map(shellQuote).join(" ")}`;
throw new CliInputError(`host/workspace ${operation} does not accept ${option.split("=", 1)[0]}; cwd belongs in the route`, {
code: "trans-host-cwd-belongs-in-route",
argument: option,
usage: replacement,
hint: "Use <provider>:/absolute/workspace for host cwd. Use exec --cwd only with a k3s workload route.",
});
}
export function parseSshRoute(target: string): ParsedSshRoute {
if (!target) throw new Error("ssh requires provider id, for example: trans D601");
const firstColon = target.indexOf(":");