From 86d6ba7461f43486b7bb1166f3176efc5e1b9ba6 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Jul 2026 03:44:59 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=8D=E7=94=A8=20UniDesk=20trans=20W?= =?UTF-8?q?indows=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-v01-aipod-spec.md | 11 +- src/selftest/cases/90-runner-image-tools.ts | 18 +- tools/tran | 335 +------------------- 3 files changed, 28 insertions(+), 336 deletions(-) diff --git a/docs/reference/spec-v01-aipod-spec.md b/docs/reference/spec-v01-aipod-spec.md index 04f056a..9b758d2 100644 --- a/docs/reference/spec-v01-aipod-spec.md +++ b/docs/reference/spec-v01-aipod-spec.md @@ -93,7 +93,16 @@ imageRef: - 通过 `toolCredentials` 获取 GitHub Issue/PR 写入 token:`agentrun-v01-tool-github-pr` -> env `GH_TOKEN`。 - 通过 `toolCredentials` 获取 UniDesk SSH 透传 token:`agentrun-v01-tool-unidesk-ssh` -> env `UNIDESK_SSH_CLIENT_TOKEN`。 - 通过 `toolCredentials` 获取 GitHub SSH 凭据:`agentrun-v01-tool-github-ssh` -> volume `/home/agentrun/.ssh`。 -- 通过声明 GitHub repo URL 的多仓库 gitbundle,装配 UniDesk repo 中的 `unidesk-*` skills、AgentRun repo 中的 `tools/trans`、`tools/tran`、`tools/apply_patch`,以及 `agent_skills` repo 中的 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`;runner 物化阶段自动把 GitHub URL 改写到 G14 git mirror,Artificer 规格本身不声明 mirror 开关或 mirror base URL。 +- 通过声明 GitHub repo URL 的多仓库 gitbundle 完成工具与 skill 装配: + - UniDesk repo 提供 primary source workspace 与 `unidesk-*` skills; + - AgentRun repo 提供 `tools/trans`、`tools/tran`、`tools/apply_patch` 等 runner tool shim; + - `agent_skills` repo 提供 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`; + - `tools/tran` 只委派 primary UniDesk workspace 的 `scripts/tran`,Windows、host 与 k3s route 解析和 frontend transport 均由 UniDesk 共享实现负责; + - runner 物化阶段自动把 GitHub URL 改写到 G14 git mirror,Artificer 规格本身不声明 mirror 开关或 mirror base URL。 +- primary source workspace 与受控远端 tool target 是不同边界: + - Agent 修改的源码仍以 `ResourceBundleRef` 物化出的 primary workspace 为唯一来源; + - 用户明确授权的 `trans ...` 指向远端 tool target,不属于旁路 source workspace; + - route 授权、SecretRef、脱敏与短连接继续由 UniDesk frontend 和 `unidesk-ssh` tool credential 约束。 - 通过 `requiredSkills[]` 在 runner 启动 backend 前校验 `dad-dev` 与 UniDesk 关键 skills 已被 materialize;缺失时必须 `required-skill-unavailable`,不得使用模型默认 skill 猜测。 - Artificer 默认是 session-capable agent。`render`/`queue submit --aipod Artificer` 在调用方没有显式传入 `sessionRef` 时,必须生成稳定的默认 `SessionRef`,`sessionId` 使用 `sess_artificer_<24 hex>` 形态,`conversationId` 默认等于 `sessionId`,并把该引用写入 Queue task/run 装配输入;后续 follow-up、补测、reviewer feedback 和 manager recovery 优先继续同一个 session,而不是每次新开 session。 diff --git a/src/selftest/cases/90-runner-image-tools.ts b/src/selftest/cases/90-runner-image-tools.ts index 0c82542..ba72fa6 100644 --- a/src/selftest/cases/90-runner-image-tools.ts +++ b/src/selftest/cases/90-runner-image-tools.ts @@ -26,23 +26,17 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(containerfile.includes("npm --version"), true, "runner image build smoke must verify npm"); assert.equal(containerfile.includes("gh --version"), true, "runner image build smoke must verify GitHub CLI"); - assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools"); + assert.equal(tran.startsWith("#!/bin/sh\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools"); assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools"); assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools"); assert.equal(agentrunGit.startsWith("#!/usr/bin/env bun\n"), true, "tools/agentrun-git must be a shebang executable discovered by gitbundle tools"); - assert.equal(tran.includes("UNIDESK_SSH_CLIENT_TOKEN"), true, "tools/tran must require the scoped UniDesk SSH client token"); - assert.equal(tran.includes("/ws/ssh"), true, "tools/tran must use the frontend SSH WebSocket path"); - assert.equal(tran.includes("apply-patch < patch.diff"), true, "tools/tran help must advertise runner-side apply-patch"); + assert.equal(tran.includes("scripts/tran"), true, "tools/tran must delegate to the primary UniDesk workspace implementation"); + assert.equal(tran.includes("UNIDESK_TRAN_REPO_ROOT"), true, "tools/tran must bind the shared implementation to the primary workspace"); + assert.equal(tran.includes("parseRoute"), false, "tools/tran must not carry a second route parser"); + assert.equal(tran.includes("does not support Windows routes"), false, "tools/tran must not reject shared Windows routes"); assert.equal(agentrunGit.includes("GIT_TERMINAL_PROMPT"), true, "tools/agentrun-git must disable interactive git prompts"); assert.equal(agentrunGit.includes("AGENTRUN_GIT_DIRECT_HOSTS"), true, "tools/agentrun-git must expose direct host policy"); - const help = await execFileAsync(path.join(context.root, "tools/tran"), ["--help"], { cwd: context.root, timeout: 10_000 }); - const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; unsupported?: string[]; valuesPrinted?: boolean }; - assert.equal(parsed.ok, true); - assert.equal(parsed.valuesPrinted, false); - assert.equal(parsed.supported?.some((line) => line.includes("script")), true); - assert.equal(parsed.supported?.some((line) => line.includes("apply-patch")), true); - assert.equal(parsed.unsupported?.includes("apply-patch"), false); const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 }); assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true); const agentrunGitHelp = await execFileAsync(path.join(context.root, "tools/agentrun-git"), ["--help"], { cwd: context.root, timeout: 10_000 }); @@ -74,7 +68,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true); assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false); - return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] }; + return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran delegates to the primary UniDesk workspace", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] }; }; async function createFakeToolBin(dir: string): Promise { diff --git a/tools/tran b/tools/tran index 35ac7d6..dc0fd7e 100755 --- a/tools/tran +++ b/tools/tran @@ -1,326 +1,15 @@ -#!/usr/bin/env bun +#!/bin/sh +set -eu -const defaultFrontendPort = 18081; -const sshInputChunkBytes = 32 * 1024; +self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +primary_workspace=$(CDPATH= cd -- "$self_dir/../../.." && pwd) +shared_tran="$primary_workspace/scripts/tran" -function jsonHelp() { - return { - ok: true, - tool: "tran", - purpose: "AgentRun runner UniDesk SSH passthrough over frontend /ws/ssh", - requiredEnv: ["UNIDESK_SSH_CLIENT_TOKEN", "UNIDESK_MAIN_SERVER_IP or UNIDESK_FRONTEND_URL"], - supported: [ - "tran ", - "tran argv ", - "tran script -- ''", - "tran :/absolute/workspace script -- ''", - "tran :/absolute/workspace apply-patch < patch.diff", - "tran :k3s kubectl ", - "tran :k3s script -- ''", - "tran :k3s::[:container] argv ", - "tran :k3s::[:container] script -- ''", - "tran :k3s::[:container] apply-patch < patch.diff", - ], - unsupported: ["upload", "download", "Windows win/ps/cmd routes"], - valuesPrinted: false, - }; -} +if [ ! -x "$shared_tran" ]; then + printf '%s\n' '{"ok":false,"failureKind":"tool-unavailable","message":"primary UniDesk workspace does not provide executable scripts/tran","source":"primary-workspace","valuesPrinted":false}' >&2 + exit 2 +fi -function writeJson(value, stream = process.stdout) { - stream.write(`${JSON.stringify(value)}\n`); -} - -function fail(failureKind, message, details = {}, exitCode = 2) { - writeJson({ ok: false, failureKind, message, ...details, valuesPrinted: false }, process.stderr); - process.exit(exitCode); -} - -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'"'"'`)}'`; -} - -function shellArgv(args) { - if (args.length === 0) return ""; - return args.map(shellQuote).join(" "); -} - -function baseUrlFromEnv(env) { - const explicit = (env.UNIDESK_FRONTEND_URL || env.UNIDESK_MAIN_SERVER_URL || "").trim(); - if (explicit) return explicit.replace(/\/+$/g, ""); - const host = (env.UNIDESK_MAIN_SERVER_IP || env.UNIDESK_MAIN_SERVER_HOST || env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST || "").trim(); - if (!host) return null; - if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/g, ""); - if (/:[0-9]+$/u.test(host)) return `http://${host}`; - const port = Number(env.UNIDESK_FRONTEND_PORT || env.UNIDESK_MAIN_SERVER_PORT || defaultFrontendPort); - return `http://${host}:${Number.isInteger(port) && port > 0 ? port : defaultFrontendPort}`; -} - -function websocketUrl(baseUrl) { - const url = new URL("/ws/ssh", baseUrl); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - return url.toString(); -} - -function parseRoute(raw) { - const parts = String(raw).split(":"); - const providerId = parts.shift() || ""; - if (!providerId) fail("schema-invalid", "tran route requires a provider id", { route: raw }); - if (parts.length === 0) return { providerId, plane: "host", workspace: null, namespace: null, resource: null, container: null, raw }; - if (parts[0] === "win") fail("unsupported-operation", "AgentRun tran does not support Windows routes yet", { route: raw }); - if (parts[0] === "k3s") { - return { - providerId, - plane: "k3s", - workspace: null, - namespace: parts[1] || null, - resource: parts[2] || null, - container: parts[3] || null, - raw, - }; - } - const workspace = parts.join(":"); - if (!workspace.startsWith("/")) fail("schema-invalid", "host workspace routes must be absolute paths", { route: raw }); - return { providerId, plane: "host", workspace, namespace: null, resource: null, container: null, raw }; -} - -async function readStdinText() { - const chunks = []; - for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString("utf8"); -} - -async function readApplyPatchHelperSource() { - const helperPath = new URL("./apply_patch", import.meta.url); - try { - const text = await Bun.file(helperPath).text(); - if (!text.startsWith("#!")) fail("infra-failed", "adjacent apply_patch helper is not executable script shaped", { helper: "tools/apply_patch" }); - return text; - } catch (error) { - fail("infra-failed", "adjacent apply_patch helper is required for runner-side tran apply-patch", { helper: "tools/apply_patch", error: error instanceof Error ? error.message : String(error) }); - } -} - -function applyPatchToolArgs(args) { - const supported = new Set(["--allow-loose", "--help", "-h"]); - for (const arg of args) { - if (!supported.has(arg)) fail("schema-invalid", `unsupported apply-patch option: ${arg}`, { supported: [...supported].sort() }); - } - return args; -} - -function base64Text(value) { - return Buffer.from(value, "utf8").toString("base64").replace(/.{1,76}/g, "$&\n").trimEnd(); -} - -async function applyPatchRemoteScript(args) { - const toolArgs = applyPatchToolArgs(args); - const [helperSource, patchText] = await Promise.all([readApplyPatchHelperSource(), readStdinText()]); - if (patchText.trim().length === 0) fail("schema-invalid", "apply-patch requires patch text on stdin"); - const helperMarker = "__AGENTRUN_TRAN_APPLY_PATCH_HELPER__"; - const patchMarker = "__AGENTRUN_TRAN_APPLY_PATCH_BODY__"; - const toolArgText = toolArgs.length > 0 ? ` ${shellArgv(toolArgs)}` : ""; - return [ - "set -eu", - "helper=$(mktemp \"${TMPDIR:-/tmp}/agentrun-apply-patch.XXXXXX\") || exit 1", - "patch_file=$(mktemp \"${TMPDIR:-/tmp}/agentrun-apply-patch-body.XXXXXX\") || exit 1", - "cleanup() { rm -f \"$helper\" \"$patch_file\"; }", - "trap cleanup EXIT HUP INT TERM", - "decode_b64() { base64 -d; }", - `decode_b64 > \"$helper\" <<'${helperMarker}'`, - base64Text(helperSource), - helperMarker, - `decode_b64 > \"$patch_file\" <<'${patchMarker}'`, - base64Text(patchText), - patchMarker, - "chmod 700 \"$helper\"", - `\"$helper\"${toolArgText} < \"$patch_file\"`, - ].join("\n"); -} - -async function scriptCommand(args) { - if (args[0] === "--") { - const rest = args.slice(1); - if (rest.length === 0) return await readStdinText(); - if (rest.length === 1) return rest[0]; - return shellArgv(rest); - } - if (args.length === 0) return await readStdinText(); - return shellArgv(args); -} - -async function hostCommand(route, args) { - if (args.length === 0) return { command: null, cwd: route.workspace, tty: true }; - const op = args[0]; - if (op === "apply-patch") return { command: await applyPatchRemoteScript(args.slice(1)), cwd: route.workspace, tty: false }; - if (op === "upload" || op === "download") { - fail("unsupported-operation", `AgentRun tran does not support ${op}; use host/source controlled tools outside the runner for that operation`, { operation: op }); - } - if (op === "script" || op === "shell") return { command: await scriptCommand(args.slice(1)), cwd: route.workspace, tty: false }; - if (op === "argv") return { command: shellArgv(args.slice(1)), cwd: route.workspace, tty: false }; - return { command: shellArgv(args), cwd: route.workspace, tty: false }; -} - -function k3sExecPrefix(route) { - const base = ["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec"]; - if (route.namespace) base.push("-n", route.namespace); - if (!route.resource) fail("schema-invalid", "k3s workload routes require namespace and resource", { route: route.raw }); - base.push(route.resource); - if (route.container) base.push("-c", route.container); - base.push("--"); - return base; -} - -async function k3sCommand(route, args) { - const op = args[0] || "kubectl"; - if (op === "upload" || op === "download") { - fail("unsupported-operation", `AgentRun tran does not support ${op}; use host/source controlled tools outside the runner for that operation`, { operation: op }); - } - if (!route.resource) { - if (op === "apply-patch") fail("schema-invalid", "k3s apply-patch requires namespace and workload route", { route: route.raw }); - if (op === "kubectl") return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", ...args.slice(1)]), cwd: null, tty: false }; - if (op === "script" || op === "shell") { - const script = await scriptCommand(args.slice(1)); - return { command: `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; ${script}`, cwd: null, tty: false }; - } - if (op === "argv") return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", ...args.slice(1)]), cwd: null, tty: false }; - return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", ...args]), cwd: null, tty: false }; - } - if (op === "apply-patch") { - const script = await applyPatchRemoteScript(args.slice(1)); - return { command: shellArgv([...k3sExecPrefix(route), "sh", "-lc", script]), cwd: null, tty: false }; - } - if (op === "script" || op === "shell") { - const script = await scriptCommand(args.slice(1)); - return { command: shellArgv([...k3sExecPrefix(route), "sh", "-lc", script]), cwd: null, tty: false }; - } - if (op === "argv") return { command: shellArgv([...k3sExecPrefix(route), ...args.slice(1)]), cwd: null, tty: false }; - return { command: shellArgv([...k3sExecPrefix(route), ...args]), cwd: null, tty: false }; -} - -async function buildOpenPayload(argv) { - if (argv.length === 0) fail("schema-invalid", "tran requires a route", { help: jsonHelp() }); - const route = parseRoute(argv[0]); - const parsed = route.plane === "k3s" ? await k3sCommand(route, argv.slice(1)) : await hostCommand(route, argv.slice(1)); - return { - providerId: route.providerId, - command: parsed.command || undefined, - cwd: parsed.cwd || undefined, - tty: parsed.tty === true, - cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, - rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, - openTimeoutMs: Math.max(15000, Math.min(Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000), 60000)), - runtimeTimeoutMs: Math.max(1000, Math.min(Number(process.env.UNIDESK_SSH_RUNTIME_TIMEOUT_MS || process.env.UNIDESK_TRAN_RUNTIME_TIMEOUT_MS || 60000), 60000)), - stdinEotOnEnd: true, - route: route.raw, - }; -} - -async function main() { - const argv = process.argv.slice(2); - if (argv[0] === "--help" || argv[0] === "help" || argv[0] === "-h") { - writeJson(jsonHelp()); - return; - } - const token = (process.env.UNIDESK_SSH_CLIENT_TOKEN || "").trim(); - if (!token) fail("secret-unavailable", "UNIDESK_SSH_CLIENT_TOKEN is required for runner-side tran"); - const baseUrl = baseUrlFromEnv(process.env); - if (!baseUrl) fail("schema-invalid", "UNIDESK_MAIN_SERVER_IP, UNIDESK_MAIN_SERVER_HOST, or UNIDESK_FRONTEND_URL is required for runner-side tran"); - const open = await buildOpenPayload(argv); - await runWebSocket(open, websocketUrl(baseUrl), token); -} - -async function runWebSocket(open, url, token) { - const ws = new WebSocket(url, { headers: { authorization: `Bearer ${token}` } }); - let exitCode = 255; - let canSend = false; - let sessionReady = false; - let settled = false; - const pending = []; - const pendingInput = []; - - const send = (value) => { - const text = JSON.stringify(value); - if (!canSend || ws.readyState !== WebSocket.OPEN) pending.push(text); - else ws.send(text); - }; - const sendInput = (value) => { - const text = JSON.stringify(value); - if (!sessionReady || ws.readyState !== WebSocket.OPEN) pendingInput.push(text); - else ws.send(text); - }; - const flush = () => { - while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()); - }; - const flushInput = () => { - if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; - while (pendingInput.length > 0) ws.send(pendingInput.shift()); - }; - const finish = (code) => { - if (settled) return; - settled = true; - clearTimeout(openTimer); - clearTimeout(runtimeTimer); - process.exit(code); - }; - const openTimer = setTimeout(() => { - if (sessionReady || settled) return; - process.stderr.write("unidesk runner tran timed out waiting for provider session\n"); - exitCode = 255; - try { ws.close(); } catch {} - }, open.openTimeoutMs); - const runtimeTimer = setTimeout(() => { - if (settled) return; - process.stderr.write(`UNIDESK_TRAN_TIMEOUT_HINT ${JSON.stringify({ code: "tran-runtime-timeout", level: "warning", route: open.route, timeoutMs: open.runtimeTimeoutMs, message: "tran exceeded the runtime limit; use short query plus poll semantics" })}\n`); - exitCode = 124; - try { ws.close(); } catch {} - finish(124); - }, open.runtimeTimeoutMs); - - ws.addEventListener("open", () => { - canSend = true; - send({ type: "ssh.open", providerId: open.providerId, command: open.command, cwd: open.cwd, tty: open.tty, cols: open.cols, rows: open.rows }); - flush(); - }); - ws.addEventListener("message", (event) => { - let message; - const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf8"); - try { - message = JSON.parse(text); - } catch { - process.stderr.write(`${text}\n`); - return; - } - if (message.type === "ssh.dispatched") return; - if (message.type === "ssh.opened") { - sessionReady = true; - clearTimeout(openTimer); - sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" }); - sendInput({ type: "ssh.eof" }); - flushInput(); - return; - } - if (message.type === "ssh.data") { - const chunk = Buffer.from(String(message.data || ""), message.encoding === "base64" ? "base64" : "utf8"); - if (message.stream === "stderr") process.stderr.write(chunk); - else process.stdout.write(chunk); - return; - } - if (message.type === "ssh.error") { - process.stderr.write(`${String(message.message || "ssh bridge error")}\n`); - exitCode = 255; - try { ws.close(); } catch {} - return; - } - if (message.type === "ssh.exit") { - exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; - try { ws.close(); } catch {} - } - }); - ws.addEventListener("close", () => finish(exitCode)); - ws.addEventListener("error", () => { - process.stderr.write("unidesk runner tran websocket error\n"); - finish(255); - }); -} - -await main(); +UNIDESK_TRAN_REPO_ROOT="$primary_workspace" +export UNIDESK_TRAN_REPO_ROOT +exec "$shared_tran" "$@"