diff --git a/AGENTS.md b/AGENTS.md index 3d184d6f..c98f912f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server cleanup plan [--min-age-hours N] [--limit N]`:只读/干跑生成主 server Docker 镜像清理计划,默认只列出至少 24 小时前创建的非保护镜像,输出 active/protected images、stale candidates、预计释放空间、风险等级和必须人工确认的 `docker image rm` 命令;禁止默认删除、禁止 prune、禁止触碰 database volume、registry storage 或 Baidu Netdisk 状态。 - `bun scripts/cli.ts server rebuild `:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建主 server Compose 内单个服务;对 database、File Browser、Code Queue 执行面、k3sctl-adapter 或未知对象返回结构化 `unsupported-server-rebuild`,规则见 `docs/reference/deployment.md` 与 `docs/reference/cicd-standardization.md`。 - `bun scripts/cli.ts provider attach [--master-server URL] [--up] [--force]` / `bun scripts/cli.ts provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]`:前者在新增计算节点上生成两项配置的 provider-gateway 挂载包;后者是只读多信号健康裁决入口,默认低噪声输出 `decision`、`healthyScopes`、`failedScopes`、`retryable` 和异常信号摘要,用来把单路径 `provider is not online`、SSH 超时、registry 失败或 proxy 失败归类为 `retryable-transient`、`service-degraded` 或 `global-offline`,完整 evidence 需显式 `--full|--raw`,规则见 `docs/reference/provider-gateway.md` 和 `docs/reference/code-queue-supervision.md`。 -- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 +- `bun scripts/cli.ts ssh [operation args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥进入 provider、host workspace、k3s 控制面或 pod workspace,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob`、`argv` 和 k3s route operation 用于避免远端补丁、Python stdin、skill 发现与分布式命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`status/health/diagnostics` 默认 compact summary 并用 `--full|--raw` 展开完整 body,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts microservice health/diagnostics/proxy code-agent-sandbox`:验证独立 Code Agent Sandbox 的 health、只读 diagnostics、trace 和 adapter/mode/credential boundary 契约,规则见 `docs/reference/code-agent-sandbox.md`。 - `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/需求/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 94b92c24..1fb8b960 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -177,7 +177,9 @@ bun scripts/cli.ts ssh D601 find /home/ubuntu --max-depth 4 --type d --icontains bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort ``` -`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。当前稳定 plane 是 `k3s`:`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 +`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`script`、`apply-patch` 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 + +当前稳定 plane 是 `k3s`:`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app`,`D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app`。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 `k3s` 必须出现在 route 的 plane 段里,禁止使用 `ssh G14 k3s ...` 或 `ssh D601 k3s ...` 这类 post-provider shorthand;正确形态是 `ssh G14:k3s kubectl ...` 或 `ssh D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl`、`logs`、`script`、`apply-patch`、`exec` 等 operation 名也不得放进任何 colon route 段,包括 namespace、workload 或 container 段;新增分布式目标时按 `{provider}:{plane}:{scope}` 扩展 route,而不是在 operation args 中新增另一套定位语法。 @@ -186,11 +188,13 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*- ```bash bun scripts/cli.ts ssh D601:k3s bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev +bun scripts/cli.ts ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch bun scripts/cli.ts ssh G14:k3s bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci printf 'kubectl get deploy -n hwlab-dev\n' | bun scripts/cli.ts ssh D601:k3s script bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80 bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)' +bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH' *** Begin Patch diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 52a192ff..aed2a470 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -26,6 +26,7 @@ export function rootHelp(): unknown { { command: "ssh skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." }, { command: "ssh find [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." }, { command: "ssh glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." }, + { command: "ssh :/absolute/workspace ", description: "Route directly into a host workspace while keeping the operation parser independent from the location." }, { command: "ssh :k3s[:namespace:workload[:container]] ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." }, { command: "ssh argv [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh script` when shell features are required." }, { command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." }, @@ -153,11 +154,13 @@ export function sshHelp(): unknown { "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]", + "bun scripts/cli.ts ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch", "bun scripts/cli.ts ssh D601:k3s", "bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev", "bun scripts/cli.ts ssh G14:k3s", "bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci", "bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'", + "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd", "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'", "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'", "bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'", @@ -166,7 +169,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.", - "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. For native k3s providers such as D601 and G14, :k3s locates the control plane, :k3s:: locates a workload, and kubectl/script/logs/apply-patch/exec are operations placed after the route.", + "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `:/absolute/workspace`; native k3s providers such as D601 and G14 use :k3s for the control plane, :k3s:: for a workload, and :k3s::/ for a pod workspace.", "Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.", "Do not use post-provider shorthand such as `ssh G14 k3s ...`; write `ssh G14:k3s ...` so location and operation stay separated.", "If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 66790ccc..9e6c5cf0 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -18,6 +18,7 @@ export interface ParsedSshRoute { namespace: string | null; resource: string | null; container: string | null; + workspace: string | null; raw: string; } @@ -40,6 +41,7 @@ export interface SshFailureHint { const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]); const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml"; +const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]); const legacyK3sOperationRouteSegments = new Set([ "guard", "kubectl", @@ -859,28 +861,94 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv export function parseSshRoute(target: string): ParsedSshRoute { if (!target) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D601"); - const parts = target.split(":"); - const [providerId, plane, ...rest] = parts; + const firstColon = target.indexOf(":"); + if (firstColon < 0) { + return hostSshRoute(target, target, null); + } + const providerId = target.slice(0, firstColon); + const tail = target.slice(firstColon + 1); if (!providerId) throw new Error("ssh route requires a provider id before ':'"); + if (tail.length === 0) { + return hostSshRoute(providerId, target, null); + } + if (tail.startsWith("/")) { + return hostSshRoute(providerId, target, tail); + } + const [plane, ...rest] = tail.split(":"); if (plane === undefined || plane.length === 0 || plane === "host") { - return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, raw: target }; + const workspace = rest.length > 0 ? rest.join(":") : null; + if (workspace !== null && !workspace.startsWith("/")) throw new Error("ssh host workspace route requires an absolute path after provider:host:"); + return hostSshRoute(providerId, target, workspace); } if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`); const [first, second, third, fourth] = rest; - const operationInRoute = [first, second, third].find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment)); + const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment)); if (operationInRoute !== undefined) throw new Error(k3sOperationInRouteMessage(target, operationInRoute)); if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container"); + const targetRoute = parseK3sRouteTargetSegments(second ?? null, third ?? null); return { providerId, plane: "k3s", entry: null, namespace: first && first.length > 0 ? first : null, - resource: second && second.length > 0 ? second : null, - container: third && third.length > 0 ? third : null, + resource: targetRoute.resource, + container: targetRoute.container, + workspace: targetRoute.workspace, raw: target, }; } +function hostSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute { + return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, workspace, raw }; +} + +function routeSegmentHead(segment: string): string { + return segment.split("/")[0] ?? segment; +} + +function parseK3sRouteTargetSegments(rawResource: string | null, rawContainer: string | null): { resource: string | null; container: string | null; workspace: string | null } { + const resourceParts = splitK3sResourceWorkspace(rawResource); + const containerParts = splitK3sContainerWorkspace(rawContainer); + return { + resource: resourceParts.resource, + container: containerParts.container, + workspace: combineK3sRouteWorkspace(resourceParts.workspace, containerParts.workspace), + }; +} + +function splitK3sResourceWorkspace(value: string | null): { resource: string | null; workspace: string | null } { + if (value === null || value.length === 0) return { resource: null, workspace: null }; + const parts = value.split("/"); + if (parts.length <= 1) return { resource: value, workspace: null }; + if (isK3sResourceKindAlias(parts[0] ?? "") && (parts[1] ?? "").length > 0) { + const workspaceParts = parts.slice(2); + return { + resource: `${parts[0]}/${parts[1]}`, + workspace: workspaceParts.length > 0 ? `/${workspaceParts.join("/")}` : null, + }; + } + return { + resource: parts[0] ?? value, + workspace: parts.length > 1 ? `/${parts.slice(1).join("/")}` : null, + }; +} + +function splitK3sContainerWorkspace(value: string | null): { container: string | null; workspace: string | null } { + if (value === null || value.length === 0) return { container: null, workspace: null }; + const parts = value.split("/"); + if (parts.length <= 1) return { container: value, workspace: null }; + return { container: parts[0] ?? value, workspace: `/${parts.slice(1).join("/")}` }; +} + +function combineK3sRouteWorkspace(first: string | null, second: string | null): string | null { + if (first !== null && second !== null && first !== second) throw new Error("ssh k3s route workspace can be specified once, either after the workload or after the container"); + return first ?? second; +} + +function isK3sResourceKindAlias(value: string): boolean { + return k3sResourceKindAliases.has(value); +} + function k3sOperationInRouteMessage(target: string, operation: string): string { const providerId = target.split(":")[0] || ""; const operationExample = operation === "guard" ? "guard" : `${operation} ...`; @@ -1053,6 +1121,7 @@ function k3sRouteTargetArgs(route: ParsedSshRoute): string[] { "--namespace", route.namespace, "--resource", normalizeK3sRouteResource(route.resource), ...(route.container === null ? [] : ["--container", route.container]), + ...(route.workspace === null ? [] : ["--workdir", route.workspace]), ]; } @@ -1108,6 +1177,7 @@ interface K3sTargetOptions { namespace: string | null; resource: string | null; container: string | null; + workspace: string | null; stdin: boolean; tty: boolean; shell: string | null; @@ -1134,7 +1204,7 @@ function buildK3sExecCommand(args: string[]): string { ...(parsed.tty ? ["-t"] : []), ...parsed.kubectlOptions, "--", - ...parsed.command, + ...withK3sWorkspace(parsed, parsed.command), ]; return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } @@ -1154,10 +1224,7 @@ function buildK3sScriptCommand(args: string[]): string { ...(parsed.container === null ? [] : ["-c", parsed.container]), ...parsed.kubectlOptions, "--", - shell, - "-s", - "--", - ...parsed.command, + ...withK3sWorkspace(parsed, [shell, "-s", "--", ...parsed.command]), ]; return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } @@ -1177,10 +1244,7 @@ function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs { parsed.resource, ...(parsed.container === null ? [] : ["-c", parsed.container]), "--", - "sh", - "-s", - "--", - ...parsed.command, + ...withK3sWorkspace(parsed, ["sh", "-s", "--", ...parsed.command]), ]; const wrapper = podApplyPatchStdinWrapper(); return { @@ -1196,6 +1260,7 @@ function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string { if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body"); if (parsed.stdin) throw new Error("ssh k3s script does not accept --stdin; stdin is always the script body"); if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container"); + if (parsed.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir"); if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s script without a workload does not accept kubectl log options"); const shell = parsed.shell ?? "sh"; return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]); @@ -1206,6 +1271,7 @@ function buildK3sLogsCommand(args: string[]): string { if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace "); if (parsed.resource === null) throw new Error("ssh k3s logs requires --deployment , --pod or --resource "); if (parsed.stdin || parsed.tty) throw new Error("ssh k3s logs does not support --stdin or --tty"); + if (parsed.workspace !== null) throw new Error("ssh k3s logs does not accept --workdir"); const kubectlArgs = [ "logs", "-n", parsed.namespace, @@ -1220,6 +1286,7 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par let namespace: string | null = null; let resource: string | null = null; let container: string | null = null; + let workspace: string | null = null; let stdin = false; let tty = false; let shell: string | null = null; @@ -1262,6 +1329,11 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par index += 1; continue; } + if (arg === "--workdir" || arg === "--cwd") { + workspace = k3sWorkspaceValue(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`); + index += 1; + continue; + } if (arg === "--shell") { if (!options.allowShell) throw new Error(`${commandName} does not support --shell`); shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`); @@ -1303,7 +1375,7 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- [args...]`); if (!options.requireCommand && options.allowCommand !== true && command.length > 0) throw new Error(`${commandName} does not accept a command after --`); - return { namespace, resource, container, stdin, tty, shell, command, kubectlOptions }; + return { namespace, resource, container, workspace, stdin, tty, shell, command, kubectlOptions }; } function k3sOptionValue(args: string[], index: number, option: string): string { @@ -1312,6 +1384,17 @@ function k3sOptionValue(args: string[], index: number, option: string): string { return value; } +function k3sWorkspaceValue(value: string, option: string): string { + if (!value.startsWith("/")) throw new Error(`${option} must be an absolute pod workspace path`); + return value; +} + +function withK3sWorkspace(parsed: K3sTargetOptions, command: string[]): string[] { + if (parsed.workspace === null) return command; + if (command.length === 0) throw new Error("ssh k3s workspace route requires a command to execute"); + return ["sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", parsed.workspace, ...command]; +} + function normalizeK3sResource(value: string): string { if (value.includes("/")) return value; return `pod/${value}`; @@ -1588,6 +1671,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const payload = { providerId: invocation.providerId, command: wrapSshRemoteCommand(parsed.remoteCommand), + cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined, tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, openTimeoutMs, diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 22c584d2..476e81f6 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -30,6 +30,14 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(shortcut.invocationKind === "argv", "safe command shortcuts must use argv quoting", shortcut); assertCondition(shortcut.remoteCommand === "'pwd'", "safe command shortcut should be shell-quoted", shortcut); + const hostWorkspace = parseSshInvocation("D601:/home/ubuntu/workspace/hwlab-dev", ["pwd"]); + assertCondition(hostWorkspace.route.plane === "host" && hostWorkspace.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host workspace route must parse provider:/absolute/path", hostWorkspace); + assertCondition(hostWorkspace.parsed.remoteCommand === "'pwd'", "host workspace route must leave operation argv independent from location", hostWorkspace); + + const hostWorkspaceLongForm = parseSshInvocation("D601:host:/home/ubuntu/workspace/hwlab-dev", ["argv", "git", "status", "--short"]); + assertCondition(hostWorkspaceLongForm.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host: workspace route must parse as the same location model", hostWorkspaceLongForm); + assertCondition(hostWorkspaceLongForm.parsed.remoteCommand === "'git' 'status' '--short'", "host workspace argv operation must stay argv-quoted", hostWorkspaceLongForm); + const script = parseSshArgs(["script", "--shell", "bash", "--", "alpha beta"]); assertCondition(script.invocationKind === "helper", "script stdin helper must be classified as helper", script); assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script); @@ -101,6 +109,18 @@ export function runSshArgvGuidanceContract(): JsonRecord { const routePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api", ["printenv", "HOSTNAME"]); assertCondition(routePodTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'printenv' 'HOSTNAME'", "pod route with container must preserve explicit pod kind", routePodTarget); + const routePodWorkspace = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api", ["pwd"]); + assertCondition(routePodWorkspace.route.resource === "pod/hwlab-cloud-api-abc" && routePodWorkspace.route.container === "api" && routePodWorkspace.route.workspace === "/workspace/app", "pod route must support a workspace suffix after the pod id", routePodWorkspace); + assertCondition(routePodWorkspace.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/workspace/app' 'pwd'", "pod workspace route must run commands through a fixed cwd wrapper", routePodWorkspace); + + const routeDeploymentWorkspaceScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["script"]); + assertCondition(routeDeploymentWorkspaceScript.route.resource === "hwlab-cloud-api" && routeDeploymentWorkspaceScript.route.workspace === "/app", "deployment shorthand route must support workspace suffix", routeDeploymentWorkspaceScript); + assertCondition(routeDeploymentWorkspaceScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-s' '--'", "pod workspace script must set cwd before shell -s consumes stdin", routeDeploymentWorkspaceScript); + + const routeApplyPatchWorkspace = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["apply-patch"]); + assertCondition(routeApplyPatchWorkspace.parsed.requiresStdin === true, "pod workspace apply-patch must still stream patch stdin", routeApplyPatchWorkspace); + assertCondition(routeApplyPatchWorkspace.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-s' '--'", "pod workspace apply-patch must set cwd before injecting the sh helper", routeApplyPatchWorkspace); + const sshLike = parseSshArgs(["echo hello"]); const hint = sshFailureHint("D601", sshLike, 255, "kex_exchange_identification: Connection closed by remote host"); assertCondition(hint !== null, "ssh-like kex failure must produce a hint", sshLike); @@ -115,8 +135,10 @@ 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 D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", helpText); assertCondition(helpText.includes("ssh D601:k3s kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl operation", helpText); assertCondition(helpText.includes("ssh G14:k3s kubectl get pipelineruns -n hwlab-ci"), "ssh help must document G14 k3s route operation", helpText); + assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd"), "ssh help must document k3s pod workspace route", helpText); assertCondition(helpText.includes("ssh D601:k3s script <<'SCRIPT'"), "ssh help must document k3s control-plane script operation", helpText); assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'"), "ssh help must document k3s pod apply-patch operation", helpText); assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'"), "ssh help must document k3s script operation", helpText);