feat: 增加 UniDesk SSH runner 工具别名

This commit is contained in:
Codex
2026-06-02 15:56:22 +08:00
parent 168c74b4ef
commit cd58ee7989
5 changed files with 260 additions and 1 deletions
@@ -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` 可执行别名
+24
View File
@@ -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",
+14
View File
@@ -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",
+1
View File
@@ -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"] },
+218
View File
@@ -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 <provider[:/workspace]|provider:k3s> <script|argv|command> [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);
}