Merge pull request #702 from pikasTech/feat-unidesk-ssh-agentrun-193-v02
feat: 为 v0.2 装配 AgentRun UniDesk SSH 透传
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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` 为准。
|
||||
- 默认 `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 装配,且 runner resource bundle 默认暴露 `unidesk-ssh` 可执行别名。
|
||||
@@ -0,0 +1,304 @@
|
||||
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 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",
|
||||
"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);
|
||||
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");
|
||||
}
|
||||
|
||||
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}]`)) } : {}),
|
||||
...(toolAliases.length > 0 ? { toolAliases } : {}),
|
||||
...(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,
|
||||
toolAliases: toolAliases.map((item) => item.name),
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
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, "");
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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.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");
|
||||
|
||||
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 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",
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user