diff --git a/docs/reference/agentrun-code-agent-dispatch.md b/docs/reference/agentrun-code-agent-dispatch.md index 8fa327be..addae289 100644 --- a/docs/reference/agentrun-code-agent-dispatch.md +++ b/docs/reference/agentrun-code-agent-dispatch.md @@ -22,9 +22,11 @@ - AgentRun run 必须使用 Git-only `resourceBundleRef`,默认 repo 是 `http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git`。 - `commitId` 必须是完整 40 字符小写 SHA,不接受 branch、tag、`HEAD` 或短 SHA。 - `workspaceRef` 只描述目标 repo/branch;实际 checkout 身份以 `resourceBundleRef.repoUrl + commitId` 为准。 +- 默认 `toolAliases` 会把 `tools/unidesk-ssh.mjs` 暴露为 runner shell 内的 `unidesk-ssh` 命令;它只实现 AgentRun Code Agent 所需的短连接 UniDesk SSH passthrough,例如 `unidesk-ssh G14:/root/hwlab script -- 'pwd'`,完整文件传输和 patch 仍走 UniDesk CLI 原入口。 +- AgentRun runtime image 已预装 `gh`,结合 `tool=github` 注入的 `GH_TOKEN` 即可访问 HWLAB/UniDesk PR 与 issue;不得把 GitHub token 放入 prompt 或 `transientEnv`。 ## 验收 - 源码合同测试:`node --test internal/agent/agentrun-dispatch.test.mjs`。 - 语法检查:`node --check internal/agent/agentrun-dispatch.mjs && node --check internal/agent/agentrun-dispatch.test.mjs`。 -- 合同必须证明 `UNIDESK_SSH_CLIENT_TOKEN` 不出现在 `transientEnv`,且 GitHub/UniDesk SSH 能力都通过 AgentRun `toolCredentials` SecretRef 装配。 +- 合同必须证明 `UNIDESK_SSH_CLIENT_TOKEN` 不出现在 `transientEnv`,GitHub/UniDesk SSH 能力都通过 AgentRun `toolCredentials` SecretRef 装配,且 runner resource bundle 默认暴露 `unidesk-ssh` 可执行别名。 diff --git a/internal/agent/agentrun-dispatch.mjs b/internal/agent/agentrun-dispatch.mjs index dba971c6..d2cd186a 100644 --- a/internal/agent/agentrun-dispatch.mjs +++ b/internal/agent/agentrun-dispatch.mjs @@ -6,6 +6,9 @@ export const DEFAULT_HWLAB_AGENTRUN_REPO_URL = "http://git-mirror-http.devops-in export const DEFAULT_HWLAB_AGENTRUN_BRANCH = "G14"; export const DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE = "deepseek"; export const DEFAULT_UNIDESK_MAIN_SERVER_ENV = "UNIDESK_MAIN_SERVER_IP"; +export const DEFAULT_HWLAB_AGENTRUN_TOOL_ALIASES = Object.freeze([ + { name: "unidesk-ssh", path: "tools/unidesk-ssh.mjs", kind: "bun-script" } +]); export const AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES = Object.freeze([ "AUTH_PASSWORD", @@ -82,6 +85,7 @@ export function createHwlabAgentRunDispatchAssembly(options = {}) { const branch = nonEmptyString(options.branch ?? DEFAULT_HWLAB_AGENTRUN_BRANCH, "branch"); const timeoutMs = positiveInteger(options.timeoutMs ?? 600000, "timeoutMs"); const unideskMainServerIp = optionalString(options.unideskMainServerIp ?? env.UNIDESK_MAIN_SERVER_IP ?? env.UNIDESK_MAIN_SERVER_HOST); + const toolAliases = normalizeResourceToolAliases(options.toolAliases ?? DEFAULT_HWLAB_AGENTRUN_TOOL_ALIASES); if (includeUnideskSshToolCredential && !unideskMainServerIp) { throw new Error("unideskMainServerIp is required when UniDesk SSH passthrough is enabled"); } @@ -99,6 +103,7 @@ export function createHwlabAgentRunDispatchAssembly(options = {}) { commitId, ...(options.subdir ? { subdir: nonEmptyString(options.subdir, "subdir") } : {}), ...(Array.isArray(options.sparsePaths) ? { sparsePaths: options.sparsePaths.map((item, index) => nonEmptyString(item, `sparsePaths[${index}]`)) } : {}), + ...(toolAliases.length > 0 ? { toolAliases } : {}), ...(options.resourceCredentialRef ? { credentialRef: options.resourceCredentialRef } : {}) }; @@ -159,6 +164,7 @@ export function createHwlabAgentRunDispatchAssembly(options = {}) { githubCredentialSource: includeGithubToolCredential ? "toolCredentials" : null, unideskSshCredentialSource: includeUnideskSshToolCredential ? "toolCredentials" : null, unideskMainServerSource: unideskMainServerIp ? "transientEnv" : null, + toolAliases: toolAliases.map((item) => item.name), transientEnvNames: transientEnv.map((item) => item.name), forbiddenTransientEnvNames: AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES.slice() } @@ -235,6 +241,24 @@ function normalizeToolCredentials(items) { }); } +function normalizeResourceToolAliases(items) { + if (!Array.isArray(items)) throw new Error("toolAliases must be an array"); + const allowedKinds = new Set(["node-script", "bun-script", "sh-script", "executable"]); + const seen = new Set(); + return items.map((item, index) => { + if (!item || typeof item !== "object") throw new Error(`toolAliases[${index}] must be an object`); + const name = nonEmptyString(item.name, `toolAliases[${index}].name`); + const aliasPath = nonEmptyString(item.path, `toolAliases[${index}].path`); + const kind = nonEmptyString(item.kind, `toolAliases[${index}].kind`); + if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new Error(`toolAliases[${index}].name must be a lowercase command name`); + if (seen.has(name)) throw new Error(`toolAliases name ${name} is duplicated`); + if (aliasPath.startsWith("/") || aliasPath.includes("..")) throw new Error(`toolAliases[${index}].path must stay within the checkout`); + if (!allowedKinds.has(kind)) throw new Error(`toolAliases[${index}].kind is not supported`); + seen.add(name); + return { name, path: aliasPath, kind }; + }); +} + async function postJson(fetchImpl, managerUrl, path, payload) { const response = await fetchImpl(`${managerUrl}${path}`, { method: "POST", diff --git a/internal/agent/agentrun-dispatch.test.mjs b/internal/agent/agentrun-dispatch.test.mjs index 65ff24c7..12e0bdd4 100644 --- a/internal/agent/agentrun-dispatch.test.mjs +++ b/internal/agent/agentrun-dispatch.test.mjs @@ -25,6 +25,9 @@ test("HWLAB AgentRun assembly grants GitHub and UniDesk SSH through toolCredenti assert.equal(assembly.runPayload.providerId, "G14"); assert.equal(assembly.runPayload.backendProfile, "deepseek"); assert.equal(assembly.runPayload.resourceBundleRef.commitId, commitId); + assert.deepEqual(assembly.runPayload.resourceBundleRef.toolAliases.map((item) => item.name), ["unidesk-ssh"]); + assert.equal(assembly.runPayload.resourceBundleRef.toolAliases[0].path, "tools/unidesk-ssh.mjs"); + assert.equal(assembly.boundaries.toolAliases.includes("unidesk-ssh"), true); assert.equal(assembly.commandPayload.payload.traceId, "trc_hwlab_agentrun_001"); assert.equal(assembly.commandPayload.payload.threadId, "thread_001"); @@ -56,6 +59,17 @@ test("HWLAB AgentRun assembly rejects reusable credentials in transientEnv", () ); }); +test("HWLAB AgentRun assembly allows explicit resource tool aliases", () => { + const assembly = createHwlabAgentRunDispatchAssembly({ + traceId: "trc_hwlab_agentrun_alias_override", + prompt: "alias override", + commitId, + unideskMainServerIp: "https://unidesk.example.test", + toolAliases: [{ name: "status-tool", path: "tools/status.mjs", kind: "node-script" }] + }); + assert.deepEqual(assembly.runPayload.resourceBundleRef.toolAliases, [{ name: "status-tool", path: "tools/status.mjs", kind: "node-script" }]); +}); + test("HWLAB AgentRun assembly does not duplicate explicit UniDesk main-server env", () => { const assembly = createHwlabAgentRunDispatchAssembly({ traceId: "trc_hwlab_agentrun_explicit_unidesk", diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index f620b53a..017d5f94 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -74,6 +74,7 @@ export const checkProfiles = Object.freeze({ { id: "check-058-tools-hwlab-gateway-shell", group: "tools", command: ["node","--check","tools/hwlab-gateway-shell.mjs"] }, { id: "check-059-tools-hwlab-gateway-tran", group: "tools", command: ["node","--check","tools/hwlab-gateway-tran.mjs"] }, { id: "check-060-tools-tran", group: "tools", command: ["node","--check","tools/tran.mjs"] }, + { id: "check-060a-tools-unidesk-ssh", group: "tools", command: ["node","--check","tools/unidesk-ssh.mjs"] }, { id: "check-061-tools-device-pod-cli", group: "tools", command: ["node","scripts/run-bun.mjs","build","tools/device-pod-cli.ts","tools/device-pod-cli.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-061a-tools-device-pod-cli-test", group: "tools", command: ["node","scripts/run-bun.mjs","test","tools/device-pod-cli.test.ts"] }, { id: "check-062-tools-device-pod-cli", group: "tools", command: ["node","--check","tools/device-pod-cli.mjs"] }, diff --git a/tools/unidesk-ssh.mjs b/tools/unidesk-ssh.mjs new file mode 100644 index 00000000..959a681b --- /dev/null +++ b/tools/unidesk-ssh.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env bun + +const route = process.argv[2] ?? ""; +const commandArgs = process.argv.slice(3); + +if (route === "" || commandArgs.includes("--help") || commandArgs.includes("-h")) { + printUsage(route === ""); +} + +const token = optionalEnv("UNIDESK_SSH_CLIENT_TOKEN"); +if (!token) fail("UNIDESK_SSH_CLIENT_TOKEN is required; request tool=unidesk-ssh through AgentRun toolCredentials."); + +const frontendUrl = frontendWebSocketUrl(requiredFrontendBaseUrl()); +const parsedRoute = parseRoute(route); +const remoteCommand = buildRemoteCommand(parsedRoute, commandArgs); +const openTimeoutMs = positiveEnv("UNIDESK_SSH_OPEN_TIMEOUT_MS", 60000); +const runtimeTimeoutMs = positiveEnv("UNIDESK_SSH_RUNTIME_TIMEOUT_MS", 60000); +const ws = new WebSocket(frontendUrl, { headers: { authorization: `Bearer ${token}` } }); + +let exitCode = 255; +let settled = false; +let canSend = false; +let sessionReady = false; +const pending = []; +const pendingSessionMessages = []; + +const openTimer = setTimeout(() => { + if (sessionReady || settled) return; + process.stderr.write("unidesk-ssh timed out waiting for provider session\n"); + closeSocket(); +}, openTimeoutMs); + +const runtimeTimer = setTimeout(() => { + if (settled) return; + exitCode = 124; + process.stderr.write("unidesk-ssh runtime timeout; use short query plus poll semantics for long work\n"); + closeSocket(); +}, runtimeTimeoutMs); + +ws.addEventListener("open", () => { + canSend = true; + send({ + type: "ssh.open", + providerId: parsedRoute.providerId, + command: remoteCommand, + cwd: parsedRoute.cwd ?? undefined, + tty: false, + stdinEotOnEnd: true, + openTimeoutMs, + runtimeTimeoutMs, + cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, + rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30 + }); + flushPending(); +}); + +ws.addEventListener("message", (event) => { + let message; + try { + message = JSON.parse(webSocketDataText(event.data)); + } catch { + process.stderr.write(`${webSocketDataText(event.data)}\n`); + return; + } + + if (message.type === "ssh.dispatched") return; + if (message.type === "ssh.opened") { + sessionReady = true; + clearTimeout(openTimer); + sendWhenSessionReady({ type: "ssh.eof" }); + flushSessionMessages(); + 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.failureKind ?? "ssh-error")}: ${String(message.message ?? "ssh bridge error")}\n`); + exitCode = 255; + closeSocket(); + return; + } + if (message.type === "ssh.exit") { + exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; + closeSocket(); + } +}); + +ws.addEventListener("close", () => finish(exitCode)); +ws.addEventListener("error", () => { + process.stderr.write("unidesk-ssh websocket error\n"); + finish(255); +}); + +function send(value) { + const text = JSON.stringify(value); + if (!canSend || ws.readyState !== WebSocket.OPEN) { + pending.push(text); + return; + } + ws.send(text); +} + +function sendWhenSessionReady(value) { + const text = JSON.stringify(value); + if (!sessionReady || ws.readyState !== WebSocket.OPEN) { + pendingSessionMessages.push(text); + return; + } + ws.send(text); +} + +function flushPending() { + while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()); +} + +function flushSessionMessages() { + if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; + while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()); +} + +function closeSocket() { + try { + ws.close(); + } catch { + finish(exitCode); + } +} + +function finish(code) { + if (settled) return; + settled = true; + clearTimeout(openTimer); + clearTimeout(runtimeTimer); + process.exit(code); +} + +function parseRoute(value) { + const providerWorkspace = /^([^:]+):(\/.*)$/u.exec(value); + if (providerWorkspace) return { providerId: providerWorkspace[1], cwd: providerWorkspace[2], plane: "host" }; + const parts = value.split(":"); + const providerId = parts[0] ?? ""; + if (!/^[A-Za-z0-9._-]+$/u.test(providerId)) fail(`invalid UniDesk provider route: ${value}`); + const plane = parts[1] ?? "host"; + if (parts.length > 2) fail("unidesk-ssh supports provider, provider:/workspace, and provider:k3s routes only; use the full UniDesk CLI for nested routes."); + if (plane !== "host" && plane !== "k3s") fail(`unsupported UniDesk route plane: ${plane}`); + return { providerId, cwd: null, plane }; +} + +function buildRemoteCommand(parsed, args) { + if (args.length === 0) fail("unidesk-ssh requires a command, for example: unidesk-ssh G14:/root/hwlab script -- 'pwd'"); + const [operation, ...rest] = args; + let command; + if (operation === "script" || operation === "shell" || operation === "sh") { + const scriptArgs = rest[0] === "--" ? rest.slice(1) : rest; + if (scriptArgs.length === 0) fail(`${operation} requires a script string`); + command = `sh -lc ${shellQuote(scriptArgs.join(" "))}`; + } else if (operation === "argv") { + if (rest.length === 0) fail("argv requires at least one command argument"); + command = rest.map(shellQuote).join(" "); + } else { + command = args.map(shellQuote).join(" "); + } + if (parsed.plane === "k3s") return `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; ${command}`; + return command; +} + +function requiredFrontendBaseUrl() { + const raw = optionalEnv("UNIDESK_FRONTEND_URL") + ?? optionalEnv("UNIDESK_MAIN_SERVER_URL") + ?? optionalEnv("UNIDESK_MAIN_SERVER_IP") + ?? optionalEnv("UNIDESK_MAIN_SERVER_HOST"); + if (!raw) fail("UNIDESK_MAIN_SERVER_IP or UNIDESK_FRONTEND_URL is required for UniDesk SSH passthrough."); + return /^https?:\/\//u.test(raw) ? raw : `http://${raw}`; +} + +function frontendWebSocketUrl(base) { + const url = new URL("/ws/ssh", base); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function optionalEnv(name) { + const value = process.env[name]?.trim() ?? ""; + return value.length > 0 ? value : null; +} + +function positiveEnv(name, fallback) { + const raw = optionalEnv(name); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) fail(`${name} must be a positive number`); + return parsed; +} + +function webSocketDataText(data) { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + return String(data); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/gu, `'"'"'`)}'`; +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(2); +} + +function printUsage(failed) { + process.stderr.write(`Usage: unidesk-ssh [args...]\n\nExamples:\n unidesk-ssh G14:/root/hwlab script -- 'pwd && git status --short --branch'\n unidesk-ssh G14 argv hostname\n unidesk-ssh G14:k3s argv kubectl get ns agentrun-v01\n`); + process.exit(failed ? 2 : 0); +}