diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 93968c7f..bacac4f5 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -1,6 +1,6 @@ --- name: unidesk-trans -description: UniDesk SSH 透传与 apply-patch 语法 — `trans ` 分布式执行入口,包含 route 语法、workspace/k3s/Windows 路由、apply-patch envelope 格式、sh/bash/py/upload/download operation 和 60s 短连接约束。用户提到 trans、tran、ssh 透传、远端执行、apply-patch、apply_patch、远端 patch、k3s route、workspace route 时使用。 +description: UniDesk SSH 透传与 apply-patch 语法 — `trans ROUTE OPERATION` 分布式执行入口,包含 route 语法、workspace/k3s/Windows 路由、apply-patch envelope 格式、sh/bash/py/upload/download operation 和 60s 短连接约束。用户提到 trans、tran、ssh 透传、远端执行、apply-patch、apply_patch、远端 patch、k3s route、workspace route 时使用。 --- # UniDesk Trans @@ -30,6 +30,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - 当前 HWLAB node/lane source workspace 的正式预检/同步入口是 `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node --lane --confirm` 和 `source-workspace status`;不要把裸 `trans : git fetch ...` 当成当前 HWLAB node/lane workspace 预检/修复入口。 - `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。 - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 +- `/mnt/` WSL host workspace 由 root 透传执行时,CLI 自动把 workspace owner uid 桥接到进程级 `SUDO_UID`,让 Git 信任 Windows 挂载目录而不写全局 `safe.directory`;非 Git 命令和普通 Linux workspace 不改变所有权信任。 - Windows route 的 `win` 是 route plane,operation 直接写 `ps`、`cmd`、`git` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要写成 `trans D601:win/... win ps`,也不要把 POSIX shell 当 Windows shell。 - 扩展 Windows helper 时保持 operation-scoped PowerShell payload;不要把多操作大脚本塞进 single `EncodedCommand`。 diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index cd1a9c70..7c412222 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -154,6 +154,26 @@ describe("ssh host apply-patch fs backend", () => { }); }); +describe("ssh WSL mounted workspace ownership", () => { + test("bridges the mounted workspace owner to Git for direct and child shell commands", () => { + const direct = parseSshInvocation("G14-WSL:/mnt/d/Work/demo", ["git", "rev-parse", "--show-toplevel"]); + const childShell = parseSshInvocation("G14-WSL:/mnt/d/Work/demo", ["bash", "--", "git status --short"]); + + for (const invocation of [direct, childShell]) { + expect(invocation.parsed.remoteCommand).toContain("UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID"); + expect(invocation.parsed.remoteCommand).toContain('export SUDO_UID="$UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID"'); + expect(invocation.parsed.remoteCommand).toContain('stat -c %u .'); + } + }); + + test("does not change ownership trust for ordinary Linux workspaces", () => { + const invocation = parseSshInvocation("D601:/home/ubuntu/workspace/demo", ["git", "status", "--short"]); + + expect(invocation.parsed.remoteCommand).not.toContain("UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID"); + expect(invocation.parsed.remoteCommand).not.toContain("SUDO_UID"); + }); +}); + describe("ssh stdout bounded streaming", () => { test("uses YAML output policy defaults and clamps env override", () => { const policy = readCliOutputPolicy(); @@ -291,7 +311,10 @@ describe("ssh backend selection", () => { "", ].join("\n")); try { - const plan = sshCaptureBackendPlan(minimalConfig("198.51.100.4"), { UNIDESK_TRANS_CONFIG_PATH: configPath } as NodeJS.ProcessEnv); + const plan = sshCaptureBackendPlan(minimalConfig("198.51.100.4"), { + UNIDESK_TRANS_CONFIG_PATH: configPath, + UNIDESK_MAIN_SERVER_IP: "198.51.100.4", + } as NodeJS.ProcessEnv); expect(plan.backend).toBe("remote-frontend-websocket"); expect(plan.remoteHost).toBe("198.51.100.4"); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 79d75e0f..a28782e0 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1105,7 +1105,7 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${operationArgs.slice(1).join(" ")}`.trim()); } const parsed = parseSshArgs(operationArgs, route.raw); - return { providerId: route.providerId, route, parsed: withTransHostProxyEnv(route, parsed) }; + return { providerId: route.providerId, route, parsed: withTransHostEnvironment(route, parsed) }; } export function parseSshRoute(target: string): ParsedSshRoute { @@ -2469,11 +2469,29 @@ function shellScriptStdinPrefix(): string { return `${shellScriptPrelude()}\n`; } -function withTransHostProxyEnv(route: ParsedSshRoute, parsed: ParsedSshArgs): ParsedSshArgs { +function withTransHostEnvironment(route: ParsedSshRoute, parsed: ParsedSshArgs): ParsedSshArgs { if (route.plane !== "host" || parsed.remoteCommand === null) return parsed; + const preludes: string[] = []; + const ownershipPrelude = transHostWorkspaceOwnershipPrelude(route.workspace); + if (ownershipPrelude !== null) preludes.push(ownershipPrelude); const rule = readTransHostProxyEnvRule(route.providerId); - if (rule === null || rule.applyTo !== "host-posix-commands") return parsed; - return { ...parsed, remoteCommand: `${transHostProxyEnvPrelude(rule)}; ${parsed.remoteCommand}` }; + if (rule !== null && rule.applyTo === "host-posix-commands") preludes.push(transHostProxyEnvPrelude(rule)); + if (preludes.length === 0) return parsed; + return { ...parsed, remoteCommand: `${preludes.join("; ")}; ${parsed.remoteCommand}` }; +} + +function transHostWorkspaceOwnershipPrelude(workspace: string | null): string | null { + if (workspace === null || !/^\/mnt\/[A-Za-z](?:\/|$)/u.test(workspace)) return null; + return [ + 'if [ "$(id -u)" -eq 0 ]; then', + ' UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID="$(stat -c %u . 2>/dev/null || true)"', + ' case "$UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID" in', + ' ""|*[!0-9]*|0) ;;', + ' *) export SUDO_UID="$UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID" ;;', + ' esac', + ' export UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID', + 'fi', + ].join("\n"); } function transHostProxyEnvPrelude(rule: TransHostProxyEnvRule): string {