fix: clarify k3s route container syntax

This commit is contained in:
Codex
2026-06-13 14:02:57 +00:00
parent 153ffb4c47
commit 4d636f7e39
6 changed files with 128 additions and 40 deletions
+4 -4
View File
@@ -183,10 +183,10 @@ export function sshHelp(): unknown {
"trans G14:k3s",
"trans G14:k3s kubectl get pipelineruns -n hwlab-ci",
"trans D601:k3s script <<'SCRIPT'",
"trans D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd",
"trans D601:k3s:hwlab-dev:hwlab-cloud-api/app apply-patch <<'PATCH'",
"trans D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api exec --cwd /app -- pwd",
"trans D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api apply-patch --cwd /app <<'PATCH'",
"trans D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch-v1 <<'PATCH'",
"tar -C /path/to/files -cf - . | trans D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk",
"tar -C /path/to/files -cf - . | trans D601:k3s:unidesk:code-queue exec --cwd /root/unidesk --stdin -- tar -xf - -C /root/unidesk",
"trans D601:win/c/test apply-patch <<'PATCH'",
"trans D601:win upload ./tool.mjs F:\\Work\\hwlab\\.tmp\\tool.mjs",
"trans D601:win download F:\\Work\\hwlab\\.tmp\\tool.mjs ./tool.mjs",
@@ -210,7 +210,7 @@ export function sshHelp(): unknown {
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. Downloads stream over `host.ssh.tcp-pool`, emit progress JSON, and may receive a caller-supplied `--inactivity-timeout-ms` from async artifact/deploy jobs so active large transfers are not killed by the generic short-command budget.",
"`apply-patch-v1` is the only legacy fallback entry: it 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; it is for host/k3s POSIX shell only. Use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
"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 `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win ps` for Windows PowerShell and `<provider>:win cmd` for Windows cmd.exe, with `<provider>:win/c/test ...` mapping the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> for a pod workspace.",
"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 `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win ps` for Windows PowerShell and `<provider>:win cmd` for Windows cmd.exe, with `<provider>:win/c/test ...` mapping the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use `<provider>:k3s` for the control plane and `<provider>:k3s:<namespace>:<workload>[:<container>]` for a workload/container. In k3s routes, `:` is the distributed route separator; `/...` is only an in-container filesystem cwd and never selects a container. Prefer operation `--cwd /path` when a container is also specified.",
"Use `win`, not `win32`; the win route is a Windows operation plane. `ps` and `cmd` both set UTF-8/Python encoding defaults, while `cmd` also sets `chcp 65001`.",
"`<provider>:win skills` discovers the current Windows user's `%USERPROFILE%\\.agents\\skills` by default; use `--scope all` to include `%USERPROFILE%\\.codex\\skills`.",
"Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.",
+103 -16
View File
@@ -49,6 +49,11 @@ export interface ParsedSshInvocation {
parsed: ParsedSshArgs;
}
interface EffectiveApplyPatchV2Invocation {
invocation: ParsedSshInvocation;
argv: string[];
}
export interface SshCaptureResult {
exitCode: number;
stdout: string;
@@ -149,7 +154,6 @@ export const sshUserToolPathPrelude = [
"export PATH",
].join("\n");
const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]);
const k3sPodRoutePrefixes = ["pod:", "po:", "pods:"];
const legacyK3sOperationRouteSegments = new Set([
"guard",
"kubectl",
@@ -1031,6 +1035,7 @@ export function parseSshRoute(target: string): ParsedSshRoute {
return hostSshRoute(providerId, target, workspace);
}
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
rejectK3sSlashKindRouteSegments(target, rest);
const normalizedRest = normalizeK3sRouteRestSegments(rest);
const [first, second, third, fourth] = normalizedRest;
const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
@@ -1414,25 +1419,48 @@ function normalizeK3sRouteRestSegments(rest: string[]): string[] {
const normalized: string[] = [];
for (let index = 0; index < rest.length; index += 1) {
const current = rest[index] ?? "";
const podPrefix = k3sPodRoutePrefixes.find((prefix) => current === prefix.slice(0, -1) || current.startsWith(prefix));
if (podPrefix === undefined) {
const kindRoute = k3sColonKindRoute(current);
if (kindRoute === null) {
normalized.push(current);
continue;
}
if (current === podPrefix.slice(0, -1)) {
if (kindRoute.name === null) {
const next = rest[index + 1];
if (next === undefined || next.length === 0) throw new Error("ssh k3s pod: route requires a pod name after pod:");
normalized.push(`pod/${next}`);
if (next === undefined || next.length === 0) throw new Error(`ssh k3s ${kindRoute.kind}: route requires a resource name after ${kindRoute.kind}:`);
normalized.push(`${kindRoute.kind}/${next}`);
index += 1;
continue;
}
const podName = current.slice(podPrefix.length);
if (podName.length === 0) throw new Error("ssh k3s pod: route requires a pod name after pod:");
normalized.push(`pod/${podName}`);
normalized.push(`${kindRoute.kind}/${kindRoute.name}`);
}
return normalized;
}
function rejectK3sSlashKindRouteSegments(target: string, rest: string[]): void {
const resourceSegment = rest[1];
if (resourceSegment === undefined || resourceSegment.length === 0) return;
const slashIndex = resourceSegment.indexOf("/");
if (slashIndex < 0) return;
const head = resourceSegment.slice(0, slashIndex);
if (!isK3sResourceKindAlias(head)) return;
throw new Error(
`ssh k3s route segment "${resourceSegment}" in "${target}" uses "/" between Kubernetes route parts. ` +
`":" is the distributed route separator and "/" is only for the in-container filesystem cwd. ` +
`Use "trans <provider>:k3s:<namespace>:${head}:<name>[:<container>] ..." for ${head} routes, ` +
`and use "--cwd /path" or a route cwd suffix only for filesystem paths.`
);
}
function k3sColonKindRoute(value: string): { kind: string; name: string | null } | null {
const colonIndex = value.indexOf(":");
const head = colonIndex < 0 ? value : value.slice(0, colonIndex);
if (!isK3sResourceKindAlias(head)) return null;
if (colonIndex < 0) return { kind: head, name: null };
const name = value.slice(colonIndex + 1);
if (name.length === 0) throw new Error(`ssh k3s ${head}: route requires a resource name after ${head}:`);
return { kind: head, name };
}
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("/");
@@ -1714,6 +1742,55 @@ function k3sRouteExecOperationArgs(args: string[]): string[] {
throw new Error("ssh k3s target exec operation requires a command to exec");
}
function effectiveApplyPatchV2Invocation(invocation: ParsedSshInvocation, argv: string[]): EffectiveApplyPatchV2Invocation {
if (invocation.route.plane !== "k3s" || isApplyPatchV2HelpArgs(argv)) return { invocation, argv };
let container: string | null = null;
let workspace: string | null = null;
const remaining: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (arg === "--container" || arg === "-c") {
container = k3sOptionValue(argv, index, `ssh ${invocation.route.raw} apply-patch ${arg}`);
index += 1;
continue;
}
const containerValue = k3sEqualsOptionValue(arg, "--container", `ssh ${invocation.route.raw} apply-patch`);
if (containerValue !== null) {
container = containerValue;
continue;
}
if (arg === "--workdir" || arg === "--cwd") {
workspace = k3sWorkspaceValue(k3sOptionValue(argv, index, `ssh ${invocation.route.raw} apply-patch ${arg}`), `ssh ${invocation.route.raw} apply-patch ${arg}`);
index += 1;
continue;
}
const workdirValue = k3sEqualsOptionValue(arg, "--workdir", `ssh ${invocation.route.raw} apply-patch`) ?? k3sEqualsOptionValue(arg, "--cwd", `ssh ${invocation.route.raw} apply-patch`);
if (workdirValue !== null) {
workspace = k3sWorkspaceValue(workdirValue, `ssh ${invocation.route.raw} apply-patch ${arg.split("=", 1)[0]}`);
continue;
}
remaining.push(arg);
}
const route = invocation.route;
if (container !== null && route.container !== null && container !== route.container) {
throw new Error("ssh k3s apply-patch container can be specified once, either in the route :<container> segment or with --container");
}
return {
invocation: {
...invocation,
route: {
...route,
container: route.container ?? container,
workspace: combineK3sRouteWorkspace(route.workspace, workspace),
},
},
argv: remaining,
};
}
function buildK3sCommand(providerId: string, args: string[]): string {
const action = args[0] ?? "";
if (action.length === 0 || action === "--help" || action === "-h" || action === "help") {
@@ -2109,7 +2186,16 @@ function k3sWorkspaceValue(value: string, option: string): string {
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];
const cwdScript = [
"if ! cd \"$1\"; then",
"rc=$?",
"printf 'UNIDESK_K3S_CWD_HINT cwd=%s message=%s\\n' \"$1\" '/... is interpreted as remote cwd, not container; use :<container> or --container <container> for container selection.' >&2",
"exit \"$rc\"",
"fi",
"shift",
"exec \"$@\"",
].join("\n");
return ["sh", "-c", cwdScript, "unidesk-cwd", parsed.workspace, ...command];
}
function normalizeK3sResource(value: string): string {
@@ -3130,18 +3216,19 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
});
}
if (operationName === "apply-patch") {
const executor: ApplyPatchV2Executor = invocation.route.plane === "win"
? { fs: createWindowsApplyPatchFileSystem(config, invocation) }
: { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) };
const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1));
const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win"
? { fs: createWindowsApplyPatchFileSystem(config, applyPatch.invocation) }
: { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) };
return await runApplyPatchV2({
executor,
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
argv: normalizedArgs.slice(1),
argv: applyPatch.argv,
timing: {
providerId: invocation.providerId,
route: invocation.route.raw,
providerId: applyPatch.invocation.providerId,
route: applyPatch.invocation.route.raw,
transport: "backend-core-broker",
},
});