From 168c74b4efcae88c6bf5b32db2665f20832751e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 15:40:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20AgentRun=20Cod?= =?UTF-8?q?e=20Agent=20=E8=B0=83=E5=BA=A6=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + .../reference/agentrun-code-agent-dispatch.md | 30 ++ internal/agent/agentrun-dispatch.mjs | 280 ++++++++++++++++++ internal/agent/agentrun-dispatch.test.mjs | 119 ++++++++ 4 files changed, 430 insertions(+) create mode 100644 docs/reference/agentrun-code-agent-dispatch.md create mode 100644 internal/agent/agentrun-dispatch.mjs create mode 100644 internal/agent/agentrun-dispatch.test.mjs diff --git a/AGENTS.md b/AGENTS.md index e1ba3032..0dce97a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - G14 GitOps CI/CD、Tekton/Argo CD、集群内 registry 和无锁镜像化发布:[docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md) - G14 CI/CD 性能基线、根因分析和加速收益估算:[docs/reference/g14-cicd-performance.md](docs/reference/g14-cicd-performance.md) - Code Agent 对话就绪与真实回复判定:[docs/reference/code-agent-chat-readiness.md](docs/reference/code-agent-chat-readiness.md) +- AgentRun 手动调度装配、UniDesk SSH passthrough 与 GitHub tool credential 边界:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md) - DEV runtime hotfix runbook 与只读审计:[docs/reference/dev-runtime-hotfix-runbook.md](docs/reference/dev-runtime-hotfix-runbook.md) - Gateway 主动出站 demo、poll/result 和本地 smoke:[docs/reference/gateway-outbound-demo.md](docs/reference/gateway-outbound-demo.md) - Device Pod 旧链接兼容入口:[docs/reference/device-pod.md](docs/reference/device-pod.md) diff --git a/docs/reference/agentrun-code-agent-dispatch.md b/docs/reference/agentrun-code-agent-dispatch.md new file mode 100644 index 00000000..8fa327be --- /dev/null +++ b/docs/reference/agentrun-code-agent-dispatch.md @@ -0,0 +1,30 @@ +# AgentRun Code Agent Dispatch + +本文定义 HWLAB Code Agent 通过 AgentRun `v0.1` 手动调度 API 执行时的装配边界。它只描述稳定的 payload、SecretRef 和 credential 归属,不替代当前 G14 DEV `/v1/agent/chat` 的真实 completed 判定。 + +## 装配入口 + +- 源码入口是 `internal/agent/agentrun-dispatch.mjs`。 +- `createHwlabAgentRunDispatchAssembly()` 生成 AgentRun `POST /api/v1/runs`、`POST /api/v1/runs/:runId/commands` 和 `POST /api/v1/runs/:runId/runner-jobs` 三段 payload。 +- `dispatchHwlabAgentRun()` 使用同一 assembly 顺序调用 AgentRun manager;调用方可以注入 `fetchImpl` 做合同测试或接入真实 manager。 +- 默认 manager URL 是 `http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080`,默认 namespace 是 `agentrun-v01`,默认 providerId 是 `G14`。 + +## Credential 边界 + +- Provider credential 只通过 `executionPolicy.secretScope.providerCredentials[]` 的 AgentRun provider SecretRef 引用,默认随 `backendProfile` 选择 `agentrun-v01-provider-deepseek`、`agentrun-v01-provider-codex` 或 `agentrun-v01-provider-minimax-m3`。 +- GitHub PR/issue 能力通过 `toolCredentials[].tool=github` 注入,默认 SecretRef 是 `agentrun-v01-tool-github-pr` key `GH_TOKEN`。 +- UniDesk SSH passthrough 通过 `toolCredentials[].tool=unidesk-ssh` 注入,默认 SecretRef 是 `agentrun-v01-tool-unidesk-ssh` key `UNIDESK_SSH_CLIENT_TOKEN`。 +- `UNIDESK_SSH_CLIENT_TOKEN` 只授予 UniDesk frontend `/ws/ssh` scoped client 能力;route allowlist 由 UniDesk frontend 配置控制。HWLAB 不持有 provider token、主 server SSH key 或完整 frontend 登录态。 +- `runnerJob.transientEnv` 只能承接短期或非敏感执行上下文,例如 device-pod session token 和 `UNIDESK_MAIN_SERVER_IP`;不得承载 GitHub token、UniDesk SSH client token、provider key、长期 SSH key 或 registry token。 + +## ResourceBundle + +- 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` 为准。 + +## 验收 + +- 源码合同测试:`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 装配。 diff --git a/internal/agent/agentrun-dispatch.mjs b/internal/agent/agentrun-dispatch.mjs new file mode 100644 index 00000000..dba971c6 --- /dev/null +++ b/internal/agent/agentrun-dispatch.mjs @@ -0,0 +1,280 @@ +export const DEFAULT_AGENTRUN_NAMESPACE = "agentrun-v01"; +export const DEFAULT_AGENTRUN_MANAGER_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080"; +export const DEFAULT_HWLAB_AGENTRUN_PROJECT_ID = "pikasTech/HWLAB"; +export const DEFAULT_HWLAB_AGENTRUN_PROVIDER_ID = "G14"; +export const DEFAULT_HWLAB_AGENTRUN_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; +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 AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES = Object.freeze([ + "AUTH_PASSWORD", + "CODEX_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENAI_API_KEY", + "PROVIDER_TOKEN", + "UNIDESK_AUTH_PASSWORD", + "UNIDESK_PROVIDER_TOKEN", + "UNIDESK_SSH_CLIENT_TOKEN" +]); + +const backendProfiles = Object.freeze(["codex", "deepseek", "minimax-m3"]); +const providerSecretKeys = Object.freeze(["auth.json", "config.toml"]); +const reusableCredentialEnvNameSet = new Set(AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES); + +export function createGithubToolCredential({ + namespace = DEFAULT_AGENTRUN_NAMESPACE, + secretName = "agentrun-v01-tool-github-pr", + secretKey = "GH_TOKEN" +} = {}) { + return { + tool: "github", + purpose: "pull-request", + secretRef: { namespace, name: secretName, keys: [secretKey] }, + projection: { kind: "env", envName: secretKey, secretKey } + }; +} + +export function createUnideskSshToolCredential({ + namespace = DEFAULT_AGENTRUN_NAMESPACE, + secretName = "agentrun-v01-tool-unidesk-ssh" +} = {}) { + return { + tool: "unidesk-ssh", + purpose: "ssh-passthrough", + secretRef: { namespace, name: secretName, keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, + projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" } + }; +} + +export function createProviderCredential({ + backendProfile = DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE, + namespace = DEFAULT_AGENTRUN_NAMESPACE, + secretName = null, + mountPath = "~/.codex" +} = {}) { + const profile = normalizeBackendProfile(backendProfile); + return { + profile, + secretRef: { + namespace, + name: secretName || `agentrun-v01-provider-${profile}`, + keys: providerSecretKeys.slice(), + mountPath + } + }; +} + +export function createHwlabAgentRunDispatchAssembly(options = {}) { + const env = options.env ?? process.env; + const traceId = nonEmptyString(options.traceId, "traceId"); + const prompt = nonEmptyString(options.prompt, "prompt"); + const commitId = fullCommitSha(options.commitId); + const backendProfile = normalizeBackendProfile(options.backendProfile ?? DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE); + const includeGithubToolCredential = options.includeGithubToolCredential !== false; + const includeUnideskSshToolCredential = options.includeUnideskSshToolCredential !== false; + const namespace = nonEmptyString(options.namespace ?? DEFAULT_AGENTRUN_NAMESPACE, "namespace"); + const managerUrl = trimTrailingSlash(nonEmptyString(options.managerUrl ?? DEFAULT_AGENTRUN_MANAGER_URL, "managerUrl")); + const projectId = nonEmptyString(options.projectId ?? DEFAULT_HWLAB_AGENTRUN_PROJECT_ID, "projectId"); + const providerId = nonEmptyString(options.providerId ?? DEFAULT_HWLAB_AGENTRUN_PROVIDER_ID, "providerId"); + const repoUrl = nonEmptyString(options.repoUrl ?? DEFAULT_HWLAB_AGENTRUN_REPO_URL, "repoUrl"); + 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); + if (includeUnideskSshToolCredential && !unideskMainServerIp) { + throw new Error("unideskMainServerIp is required when UniDesk SSH passthrough is enabled"); + } + + const toolCredentials = [ + ...(includeGithubToolCredential ? [createGithubToolCredential({ namespace })] : []), + ...(includeUnideskSshToolCredential ? [createUnideskSshToolCredential({ namespace })] : []), + ...normalizeToolCredentials(options.toolCredentials ?? []) + ]; + const transientEnv = normalizeTransientEnv(transientEnvWithUnideskMainServer(options.transientEnv ?? [], unideskMainServerIp)); + const sessionRef = createSessionRef(options); + const resourceBundleRef = { + kind: "git", + repoUrl, + commitId, + ...(options.subdir ? { subdir: nonEmptyString(options.subdir, "subdir") } : {}), + ...(Array.isArray(options.sparsePaths) ? { sparsePaths: options.sparsePaths.map((item, index) => nonEmptyString(item, `sparsePaths[${index}]`)) } : {}), + ...(options.resourceCredentialRef ? { credentialRef: options.resourceCredentialRef } : {}) + }; + + const runPayload = { + tenantId: "hwlab", + projectId, + workspaceRef: { + kind: "git-worktree", + repo: repoUrl, + branch + }, + ...(sessionRef ? { sessionRef } : { sessionRef: null }), + resourceBundleRef, + providerId, + backendProfile, + executionPolicy: { + sandbox: options.sandbox ?? "workspace-write", + approval: options.approval ?? "never", + timeoutMs, + network: options.network ?? "default", + secretScope: { + allowCredentialEcho: false, + providerCredentials: [createProviderCredential({ backendProfile, namespace })], + ...(toolCredentials.length > 0 ? { toolCredentials } : {}) + } + }, + traceSink: options.traceSink ?? null + }; + + const commandPayload = { + type: "turn", + payload: { + prompt, + traceId, + projectId, + ...(options.conversationId ? { conversationId: nonEmptyString(options.conversationId, "conversationId") } : {}), + ...(options.sessionId ? { sessionId: nonEmptyString(options.sessionId, "sessionId") } : {}), + ...(options.threadId ? { threadId: nonEmptyString(options.threadId, "threadId") } : {}), + providerProfile: backendProfile, + dispatchSource: "hwlab-code-agent" + }, + idempotencyKey: options.commandIdempotencyKey ?? `hwlab:${traceId}:turn` + }; + + const runnerJobPayload = { + idempotencyKey: options.runnerJobIdempotencyKey ?? `hwlab:${traceId}:runner-job`, + ...(options.attemptId ? { attemptId: nonEmptyString(options.attemptId, "attemptId") } : {}), + transientEnv + }; + + return { + managerUrl, + runPayload, + commandPayload, + runnerJobPayload, + boundaries: { + valuesPrinted: false, + githubCredentialSource: includeGithubToolCredential ? "toolCredentials" : null, + unideskSshCredentialSource: includeUnideskSshToolCredential ? "toolCredentials" : null, + unideskMainServerSource: unideskMainServerIp ? "transientEnv" : null, + transientEnvNames: transientEnv.map((item) => item.name), + forbiddenTransientEnvNames: AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES.slice() + } + }; +} + +export async function dispatchHwlabAgentRun(options = {}) { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") throw new Error("dispatchHwlabAgentRun requires fetchImpl or global fetch"); + const assembly = options.assembly ?? createHwlabAgentRunDispatchAssembly(options); + const managerUrl = trimTrailingSlash(options.managerUrl ?? assembly.managerUrl); + const run = await postJson(fetchImpl, managerUrl, "/api/v1/runs", assembly.runPayload); + const runId = nonEmptyString(run.id, "run.id"); + const command = await postJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/commands`, assembly.commandPayload); + const commandId = nonEmptyString(command.id, "command.id"); + const runnerJobPayload = { ...assembly.runnerJobPayload, commandId }; + const runnerJob = await postJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, runnerJobPayload); + return { + managerUrl, + run, + command, + runnerJob, + requests: { + runPayload: assembly.runPayload, + commandPayload: assembly.commandPayload, + runnerJobPayload + }, + boundaries: assembly.boundaries + }; +} + +function createSessionRef(options) { + const sessionId = optionalString(options.sessionId); + const conversationId = optionalString(options.conversationId); + const threadId = optionalString(options.threadId); + if (!sessionId && !conversationId && !threadId) return null; + return { + sessionId: sessionId || conversationId || threadId, + ...(conversationId ? { conversationId } : {}), + ...(threadId ? { threadId } : {}), + metadata: { + source: "hwlab-code-agent", + traceId: optionalString(options.traceId) ?? null + } + }; +} + +function normalizeTransientEnv(items) { + const seen = new Set(); + return items.filter(Boolean).map((item, index) => { + const name = nonEmptyString(item.name, `transientEnv[${index}].name`); + if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new Error(`transientEnv[${index}].name must be an uppercase env name`); + if (reusableCredentialEnvNameSet.has(name)) throw new Error(`transientEnv ${name} must use AgentRun tool/provider credential assembly instead`); + if (seen.has(name)) throw new Error(`transientEnv ${name} is duplicated`); + seen.add(name); + return { + name, + value: nonEmptyString(item.value, `transientEnv[${index}].value`), + sensitive: item.sensitive !== false + }; + }); +} + +function transientEnvWithUnideskMainServer(items, unideskMainServerIp) { + if (!unideskMainServerIp) return items; + if (items.some((item) => item?.name === DEFAULT_UNIDESK_MAIN_SERVER_ENV)) return items; + return [...items, { name: DEFAULT_UNIDESK_MAIN_SERVER_ENV, value: unideskMainServerIp, sensitive: false }]; +} + +function normalizeToolCredentials(items) { + return items.map((item, index) => { + if (!item || typeof item !== "object") throw new Error(`toolCredentials[${index}] must be an object`); + return item; + }); +} + +async function postJson(fetchImpl, managerUrl, path, payload) { + const response = await fetchImpl(`${managerUrl}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload) + }); + const text = await response.text(); + const body = text ? JSON.parse(text) : null; + if (!response.ok) throw new Error(`AgentRun POST ${path} failed: status=${response.status} body=${JSON.stringify(body).slice(0, 500)}`); + return body; +} + +function normalizeBackendProfile(value) { + const profile = nonEmptyString(value, "backendProfile"); + if (!backendProfiles.includes(profile)) throw new Error(`unsupported AgentRun backendProfile ${JSON.stringify(profile)}`); + return profile; +} + +function fullCommitSha(value) { + const commitId = nonEmptyString(value, "commitId"); + if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new Error("commitId must be a full 40-character lowercase git commit sha"); + return commitId; +} + +function positiveInteger(value, fieldName) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${fieldName} must be a positive integer`); + return parsed; +} + +function nonEmptyString(value, fieldName) { + const text = optionalString(value); + if (!text) throw new Error(`${fieldName} is required`); + return text; +} + +function optionalString(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function trimTrailingSlash(value) { + return nonEmptyString(value, "url").replace(/\/+$/u, ""); +} diff --git a/internal/agent/agentrun-dispatch.test.mjs b/internal/agent/agentrun-dispatch.test.mjs new file mode 100644 index 00000000..65ff24c7 --- /dev/null +++ b/internal/agent/agentrun-dispatch.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_UNIDESK_MAIN_SERVER_ENV, + createHwlabAgentRunDispatchAssembly, + dispatchHwlabAgentRun +} from "./agentrun-dispatch.mjs"; + +const commitId = "8da27c83fa56d87e78e488a819afe898cf97c62b"; + +test("HWLAB AgentRun assembly grants GitHub and UniDesk SSH through toolCredentials", () => { + const assembly = createHwlabAgentRunDispatchAssembly({ + traceId: "trc_hwlab_agentrun_001", + prompt: "检查 HWLAB 工作区并给出状态。", + commitId, + conversationId: "conv_001", + sessionId: "sess_001", + threadId: "thread_001", + unideskMainServerIp: "https://unidesk.example.test" + }); + + assert.equal(assembly.runPayload.tenantId, "hwlab"); + assert.equal(assembly.runPayload.projectId, "pikasTech/HWLAB"); + assert.equal(assembly.runPayload.providerId, "G14"); + assert.equal(assembly.runPayload.backendProfile, "deepseek"); + assert.equal(assembly.runPayload.resourceBundleRef.commitId, commitId); + assert.equal(assembly.commandPayload.payload.traceId, "trc_hwlab_agentrun_001"); + assert.equal(assembly.commandPayload.payload.threadId, "thread_001"); + + const secretScope = assembly.runPayload.executionPolicy.secretScope; + assert.equal(secretScope.allowCredentialEcho, false); + assert.equal(secretScope.providerCredentials[0].profile, "deepseek"); + assert.equal(secretScope.providerCredentials[0].secretRef.name, "agentrun-v01-provider-deepseek"); + const tools = secretScope.toolCredentials; + assert.equal(tools.some((item) => item.tool === "github" && item.projection.envName === "GH_TOKEN"), true); + assert.equal(tools.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN"), true); + + const transientEnv = assembly.runnerJobPayload.transientEnv; + assert.equal(transientEnv.find((item) => item.name === DEFAULT_UNIDESK_MAIN_SERVER_ENV)?.value, "https://unidesk.example.test"); + assert.equal(transientEnv.some((item) => item.name === "UNIDESK_SSH_CLIENT_TOKEN"), false); + assert.equal(assembly.boundaries.unideskSshCredentialSource, "toolCredentials"); + assert.equal(assembly.boundaries.githubCredentialSource, "toolCredentials"); +}); + +test("HWLAB AgentRun assembly rejects reusable credentials in transientEnv", () => { + assert.throws( + () => createHwlabAgentRunDispatchAssembly({ + traceId: "trc_hwlab_agentrun_bad_env", + prompt: "bad env", + commitId, + unideskMainServerIp: "https://unidesk.example.test", + transientEnv: [{ name: "UNIDESK_SSH_CLIENT_TOKEN", value: "test-unidesk-ssh-client-token" }] + }), + /must use AgentRun tool\/provider credential assembly/u + ); +}); + +test("HWLAB AgentRun assembly does not duplicate explicit UniDesk main-server env", () => { + const assembly = createHwlabAgentRunDispatchAssembly({ + traceId: "trc_hwlab_agentrun_explicit_unidesk", + prompt: "explicit unidesk main server", + commitId, + unideskMainServerIp: "https://unidesk.example.test", + transientEnv: [{ name: DEFAULT_UNIDESK_MAIN_SERVER_ENV, value: "https://explicit.example.test", sensitive: false }] + }); + const entries = assembly.runnerJobPayload.transientEnv.filter((item) => item.name === DEFAULT_UNIDESK_MAIN_SERVER_ENV); + assert.equal(entries.length, 1); + assert.equal(entries[0].value, "https://explicit.example.test"); +}); + +test("HWLAB AgentRun assembly requires full commit and main server when UniDesk SSH is enabled", () => { + assert.throws( + () => createHwlabAgentRunDispatchAssembly({ traceId: "trc_bad_commit", prompt: "bad", commitId: "HEAD", unideskMainServerIp: "https://unidesk.example.test" }), + /full 40-character/u + ); + assert.throws( + () => createHwlabAgentRunDispatchAssembly({ traceId: "trc_no_unidesk", prompt: "bad", commitId }), + /unideskMainServerIp is required/u + ); +}); + +test("dispatchHwlabAgentRun posts run command and runner job with commandId", async () => { + const requests = []; + const fetchImpl = async (url, init) => { + const request = { url, method: init.method, body: JSON.parse(init.body) }; + requests.push(request); + if (url.endsWith("/api/v1/runs")) return jsonResponse({ id: "run_hwlab_001" }); + if (url.endsWith("/api/v1/runs/run_hwlab_001/commands")) return jsonResponse({ id: "cmd_hwlab_001" }); + if (url.endsWith("/api/v1/runs/run_hwlab_001/runner-jobs")) return jsonResponse({ id: "job_hwlab_001", jobName: "agentrun-v01-runner-selftest" }); + return jsonResponse({ error: "unexpected path" }, { status: 404 }); + }; + const result = await dispatchHwlabAgentRun({ + fetchImpl, + traceId: "trc_hwlab_dispatch_001", + prompt: "dispatch smoke", + commitId, + unideskMainServerIp: "https://unidesk.example.test" + }); + + assert.equal(result.run.id, "run_hwlab_001"); + assert.equal(result.command.id, "cmd_hwlab_001"); + assert.equal(result.runnerJob.jobName, "agentrun-v01-runner-selftest"); + assert.deepEqual(requests.map((item) => new URL(item.url).pathname), [ + "/api/v1/runs", + "/api/v1/runs/run_hwlab_001/commands", + "/api/v1/runs/run_hwlab_001/runner-jobs" + ]); + assert.equal(requests[2].body.commandId, "cmd_hwlab_001"); + assert.equal(requests[2].body.transientEnv.some((item) => item.name === "UNIDESK_SSH_CLIENT_TOKEN"), false); +}); + +function jsonResponse(body, { status = 200 } = {}) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body) + }; +} From cd58ee7989e3f01ca3a2cffba5041cb0ca4eeed4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 15:56:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20UniDesk=20SSH?= =?UTF-8?q?=20runner=20=E5=B7=A5=E5=85=B7=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reference/agentrun-code-agent-dispatch.md | 4 +- internal/agent/agentrun-dispatch.mjs | 24 ++ internal/agent/agentrun-dispatch.test.mjs | 14 ++ scripts/src/check-plan.mjs | 1 + tools/unidesk-ssh.mjs | 218 ++++++++++++++++++ 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 tools/unidesk-ssh.mjs 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); +}