diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index b168e53..0f2d6ec 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -92,7 +92,7 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 - CLI 配置必须显式校验;部署关键值不得静默 fallback。 - CLI 调用 manager REST API;不得直接连 Postgres 或读 Kubernetes Secret value。 - CLI 可以显示 SecretRef 名称、key、credential source、tool credential scope 和 readiness,但不得显示 Secret value、Codex `auth.json`、Codex `config.toml`、DSN password、token、SSH private key 或 URL credential。 -- CLI 不得把 GitHub token、provider key 或长期 SSH key 通过 `transientEnv` 传入 runner;涉及 agent shell/tool 授权时,必须先按 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md) 的 `toolCredentials` 装配路径实现,再提供 CLI 参数。 +- CLI 不得把 GitHub token、UniDesk SSH client token、provider key 或长期 SSH key 通过 `transientEnv` 传入 runner;涉及 agent shell/tool 授权时,必须先按 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md) 的 `toolCredentials` 装配路径实现,再提供 CLI 参数。非敏感服务地址例如 `UNIDESK_MAIN_SERVER_IP` 可以作为 runner-job `transientEnv` 的执行上下文注入。 - Debug 子命令可以用于开发,但综合联调和测试规格不得用 debug 子命令作为通过证据。 ## 测试规格 diff --git a/docs/reference/spec-v01-hwlab-manual-dispatch.md b/docs/reference/spec-v01-hwlab-manual-dispatch.md index 61a8525..3a48f57 100644 --- a/docs/reference/spec-v01-hwlab-manual-dispatch.md +++ b/docs/reference/spec-v01-hwlab-manual-dispatch.md @@ -50,6 +50,7 @@ AgentRun `v0.1` 承接 HWLAB v0.2 时,只吸收原有 Code Agent 的通用执 | 固定 repo workspace 执行 | `internal/cloud/code-agent-contract.ts`、`docs/reference/code-agent-chat-readiness.md` | `ResourceBundleRef` 使用 Git-only `repoUrl + full commitId` checkout 到隔离 workspace | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | provider profile 隔离和 Secret 不泄露 | `internal/cloud/code-agent-contract.ts`、`docs/reference/code-agent-chat-readiness.md` | `ProfileRef/SecretRef` profile-scoped 投影、缺失为 `secret-unavailable`、禁止 fallback 和泄露值 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-adapter.md](spec-v01-backend-adapter.md) | | device-pod 短期会话 env 注入 | `internal/cloud/server-code-agent-http.ts` 的 `codeAgentDevicePodAuthEnv()` | `runner-jobs.transientEnv` 只在本次 Kubernetes Job env 中生效;只记录 name/count,不保存或输出 value | [spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md)、[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md) | +| UniDesk SSH passthrough | HWLAB Code Agent 通过 `tran` 访问 G14/D601/HWLAB/GitHub 维护面 | `toolCredentials[].tool=unidesk-ssh` 注入 `UNIDESK_SSH_CLIENT_TOKEN`,`transientEnv` 只注入非敏感 `UNIDESK_MAIN_SERVER_IP`;UniDesk frontend 负责 route allowlist | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md) | | provider/backend/cancel 等失败可区分 | `scripts/src/code-agent-response-contract.mjs`、`internal/cloud/code-agent-chat.ts` | failureKind 最小矩阵和 JSON 错误响应 | [spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md)、[spec-v01-backend-adapter.md](spec-v01-backend-adapter.md) | | stdout/stderr/tool 输出必须有界 | `docs/reference/code-agent-chat-readiness.md`、`internal/cloud/code-agent-trace-store.ts` | `command_output`/`tool_call` 记录摘要、字节数、截断标记和必要引用 | [spec-v01-backend-adapter.md](spec-v01-backend-adapter.md) | | runner/job 失败需要定位证据 | `internal/cloud/server-code-agent-http.ts` 的 trace/result 可见性 | runner job identity、attempt、jobName、pod/log identity 和最小 phase/exit 摘要 | [spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md)、[spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md) | @@ -71,7 +72,7 @@ AgentRun `v0.1` 承接 HWLAB v0.2 时,只吸收原有 Code Agent 的通用执 响应必须短返回 JSON,不等待完整模型 turn,至少包含:`runId`、`commandId`、`attemptId`、`jobName`、`namespace`、`runnerId`、`logPath` 或 `podIdentity`、后续 `commands show` 与 `events` 轮询入口。重复提交若 payload 不同,必须结构化失败,不能创建第二个同名业务 attempt。 -`transientEnv` 是 runner-job 层的临时执行上下文,不是 AgentRun run 的 durable fact。manager 不对条目数量设固定上限,只校验数组形态、env name 合法且唯一、value 非空和单值长度;payload hash 只保存 value hash,response、event、dry-run manifest 和错误详情不得输出明文 value。业务授权仍由 HWLAB 自己负责,AgentRun 只把调度方明确提供的短期 env 交给本次 runner。 +`transientEnv` 是 runner-job 层的临时执行上下文,不是 AgentRun run 的 durable fact。manager 不对条目数量设固定上限,只校验数组形态、env name 合法且唯一、value 非空和单值长度;payload hash 只保存 value hash,response、event、dry-run manifest 和错误详情不得输出明文 value。业务授权仍由 HWLAB 自己负责,AgentRun 只把调度方明确提供的短期 env 交给本次 runner。UniDesk SSH passthrough 的长期 token 不得放入 `transientEnv`;HWLAB dispatcher 只能把 `UNIDESK_MAIN_SERVER_IP` 这类非敏感定位信息放入 `transientEnv`,并通过 run `executionPolicy.secretScope.toolCredentials[]` 请求 `tool=unidesk-ssh`。 ## Run / Command 映射 @@ -85,6 +86,7 @@ HWLAB canary 创建 run 时应使用以下字段口径: | `backendProfile` | `deepseek`、`codex` 或 `minimax-m3`,由 HWLAB 或调度方显式选择;缺少 matching SecretRef 必须失败,不 fallback。 | | `workspaceRef` | 必须引用 ResourceBundleRef 中的 Git-only full commit;不得由 runner 猜 host path。 | | `executionPolicy` | sandbox、network、timeout、secretScope 必须显式,不得由 HWLAB 扩大 AgentRun Secret 范围。 | +| `executionPolicy.secretScope.toolCredentials[]` | 需要 UniDesk SSH passthrough 时必须声明 `tool=unidesk-ssh`、`purpose=ssh-passthrough`、SecretRef `agentrun-v01-tool-unidesk-ssh`、projection env `UNIDESK_SSH_CLIENT_TOKEN`;不得把 token 放入 command payload 或 runner-job transientEnv。 | | `traceSink` | 可指向 HWLAB trace adapter;为 `null` 时 HWLAB 仍可通过 AgentRun events 轮询。 | Command 第一阶段要求 `type=turn` 和 `type=steer`。`turn` 保存用户原始 prompt、conversation metadata、profile 选择和 HWLAB trace correlation;`steer` 保存运行中引导文本,并由 runner 在同 run active turn 期间转发到 backend。业务 cancel 仍走 run/command cancel API,不用 `steer` 伪装。不得把 cookie、session token、provider credential、device internal token 或 Secret value 写入 payload。 diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index bdf71ba..73abd38 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -44,10 +44,10 @@ P0 最小 JSON 形态: | --- | --- | --- | --- | | Provider credential | `ProfileRef` / `executionPolicy.secretScope.providerCredentials[]` | profile-scoped 只读 Secret projection,再复制到 per-run writable `CODEX_HOME` | 只服务 `codex`/`deepseek`/`minimax-m3` backend profile;缺失为 `secret-unavailable`,不得 fallback。 | | Git resource credential | `ResourceBundleRef.credentialRef` | 只服务 resource materialization 的 Git fetch/checkout | 只能用于拉取 `ResourceBundleRef.repoUrl` 对应代码,不得暴露给 agent shell 作为通用 GitHub token。 | -| Tool credential | `executionPolicy.secretScope.toolCredentials[]` | 由 runner 按 tool scope 投影为文件或 env,并只暴露给当前 run/command 允许的工具 | 用于 GitHub PR、issue、artifact registry 等 agent shell 工具能力;不等同于 AgentRun integration,不触发 GitHub sink/OA/Event 之类外部动作记录。 | -| Short-lived execution context | runner-job `transientEnv` | 单次 Job env,response/dry-run/event 只显示 name/hash | 只用于业务 dispatcher 生成的短期上下文,例如 HWLAB device-pod session token;不得承载 provider credential、GitHub token、长期 SSH key 或可复用 API key。 | +| Tool credential | `executionPolicy.secretScope.toolCredentials[]` | 由 runner 按 tool scope 投影为文件或 env,并只暴露给当前 run/command 允许的工具 | 用于 GitHub PR、issue、UniDesk SSH passthrough、artifact registry 等 agent shell 工具能力;不等同于 AgentRun integration,不触发 GitHub sink/OA/Event 之类外部动作记录。 | +| Short-lived execution context | runner-job `transientEnv` | 单次 Job env,response/dry-run/event 只显示 name/hash | 只用于业务 dispatcher 生成的短期上下文,例如 HWLAB device-pod session token 和非敏感服务地址;不得承载 provider credential、GitHub token、UniDesk SSH client token、长期 SSH key 或可复用 API key。 | -`toolCredentials` 是装配 SPEC 中的受控扩展槽位,用于把 agent 运行时需要的外部工具授权从“临时 env”收敛为 SecretRef。第一版实现可以只支持 GitHub PR 验收所需的最小形态,例如: +`toolCredentials` 是装配 SPEC 中的受控扩展槽位,用于把 agent 运行时需要的外部工具授权从“临时 env”收敛为 SecretRef。`v0.1` 支持 GitHub PR/issue 与 UniDesk SSH passthrough 所需的最小 env projection,例如: ```json { @@ -61,6 +61,16 @@ P0 最小 JSON 形态: "keys": ["GH_TOKEN"] }, "projection": { "kind": "env", "envName": "GH_TOKEN" } + }, + { + "tool": "unidesk-ssh", + "purpose": "ssh-passthrough", + "secretRef": { + "namespace": "agentrun-v01", + "name": "agentrun-v01-tool-unidesk-ssh", + "keys": ["UNIDESK_SSH_CLIENT_TOKEN"] + }, + "projection": { "kind": "env", "envName": "UNIDESK_SSH_CLIENT_TOKEN" } } ] } @@ -73,7 +83,8 @@ P0 最小 JSON 形态: - runner 渲染 Job 时只能按当前 run 的 `secretScope` 投影被授权的 tool credential;不能枚举 namespace 内所有 Secret。 - dry-run manifest、runner job record、event、trace、日志和 CLI 输出只能显示 tool、purpose、SecretRef 名称/key、projection kind 和 `valuesPrinted=false`。 - GitHub PR 能力属于 agent shell/tool 运行能力,不是 AgentRun Queue integration,也不要求新增 GitHub sink、OA sink、notification 或 Event Flow。 -- 在 `toolCredentials` 实现前,发现 agent shell 缺少 `gh`、`curl`、SSH key 或 GitHub token,只能记录为装配能力缺口;不得用 `transientEnv` 或 issue 评论里的明文 token 绕过。 +- `tool=unidesk-ssh` 只允许投影 Secret key `UNIDESK_SSH_CLIENT_TOKEN` 到同名 env。该 token 是 UniDesk frontend `/ws/ssh` 的 scoped client token,route allowlist 由 UniDesk frontend 配置约束;它不得携带 provider token、主 server SSH key 或完整 frontend 登录态。 +- 发现 agent shell 缺少 `gh`、`curl`、UniDesk SSH passthrough token 或其他工具凭证时,只能记录为装配能力缺口;不得用 `transientEnv` 或 issue 评论里的明文 token 绕过。 ## HWLAB v0.2 承接口径 @@ -153,10 +164,10 @@ HWLAB v0.2 原有 Code Agent 已经验证了 profile、session、workspace 和 S ### A2b Tool credential 验收 -- GitHub PR、issue 或其他 shell/tool 授权只能通过 `executionPolicy.secretScope.toolCredentials[]` 的 SecretRef 装配进入 runner。 +- GitHub PR、issue、UniDesk SSH passthrough 或其他 shell/tool 授权只能通过 `executionPolicy.secretScope.toolCredentials[]` 的 SecretRef 装配进入 runner。 - CLI、Queue task、runner job response、dry-run manifest、event 和日志不得输出 token、SSH private key 或 credential 文件正文。 - 缺少 tool credential 时,run/command 必须返回可判定的 `secret-unavailable`、`tenant-policy-denied` 或明确 blocker,不能伪装成 agent 业务失败。 -- `transientEnv` 不得用于 GitHub token、长期 SSH key、provider API key 或其他可复用 credential。 +- `transientEnv` 不得用于 GitHub token、UniDesk SSH client token、长期 SSH key、provider API key 或其他可复用 credential。 ### A3 SessionRef 验收 @@ -189,4 +200,4 @@ HWLAB v0.2 原有 Code Agent 已经验证了 profile、session、workspace 和 S | `ProfileRef` | 已实现/待 MiniMax-M3 主闭环 | `codex` 与 `deepseek` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证;`minimax-m3` 已进入 profile/SecretRef 装配,需要完成真实 CLI 手动验收。 | | `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 | | `ResourceBundleRef` | 已实现 Git-only materialization | `repoUrl + full commitId` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 commit/tree/workspace 摘要。 | -| `toolCredentials` | 已实现最小 env projection | GitHub PR 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 先支持 `tool=github`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 | +| `toolCredentials` | 已实现最小 env projection | GitHub PR 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 | diff --git a/docs/reference/spec-v01-secret-distribution.md b/docs/reference/spec-v01-secret-distribution.md index 1bf644e..dc1003a 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -20,7 +20,7 @@ | Codex stdio profile 凭据文件 | 真实 Code Agent backend 调上游模型 | runner 或 backend adapter | `codex`、`deepseek` 与 `minimax-m3` 均使用 `auth.json`/`config.toml` 文件形态,只通过 profile-scoped Kubernetes SecretRef 文件投影注入,不写入 run payload。 | | Git SSH deploy key | Tekton checkout source/GitOps promotion,Argo 读取 GitOps branch | Tekton、Argo CD | 只存在于 `agentrun-ci` 或 `argocd` Secret;不进入 runtime Pod。 | | Registry credential | push/pull private registry | Tekton、runtime imagePullSecret | 只作为 ServiceAccount/imagePullSecret 引用。 | -| Tool credential | GitHub PR、issue、artifact registry 等 agent shell/tool 授权 | runner/backend adapter | 必须通过 `executionPolicy.secretScope.toolCredentials[]` 的 SecretRef 装配进入运行时;不是 Queue integration,也不能用 `transientEnv` 承载长期 credential。 | +| Tool credential | GitHub PR、issue、UniDesk SSH passthrough、artifact registry 等 agent shell/tool 授权 | runner/backend adapter | 必须通过 `executionPolicy.secretScope.toolCredentials[]` 的 SecretRef 装配进入运行时;不是 Queue integration,也不能用 `transientEnv` 承载长期 credential。 | | Future tenant credential | tenant 专属工具或外部服务 | runner/backend adapter | 必须先扩展装配 SPEC 的 SecretRef 和 secret scope,再允许 run 引用。 | ## 固定命名建议 @@ -35,6 +35,8 @@ | Provider config | 非敏感 base URL/model 可以来自 `config.toml` 或 ConfigMap;credential value 不得放入 ConfigMap。 | | Tekton Git SSH Secret | `agentrun-ci/agentrun-git-ssh` | | Argo Git SSH Secret | `argocd/agentrun-git-ssh` | +| GitHub Tool Secret | `agentrun-v01-tool-github-pr` key `GH_TOKEN` | +| UniDesk SSH Tool Secret | `agentrun-v01-tool-unidesk-ssh` key `UNIDESK_SSH_CLIENT_TOKEN` | | Runtime ServiceAccount | `agentrun-v01-mgr`、`agentrun-v01-runner` | 命名可以在实现时因集群约束调整,但必须满足 lane 独立、用途单一、最小 RBAC 和不跨 `v0.1`/`v0.2` 复用的原则。 @@ -66,7 +68,7 @@ Secret 创建和轮换必须通过 Kubernetes 密钥管理完成。`deploy/deplo ## Run secretScope 合同 -Run 的 `executionPolicy.secretScope` 只能包含引用,不包含值。provider credential 使用 `providerCredentials[]`;GitHub PR 等 agent shell/tool 授权使用装配 SPEC 定义的 `toolCredentials[]`,不得混入 `transientEnv`。示例形态: +Run 的 `executionPolicy.secretScope` 只能包含引用,不包含值。provider credential 使用 `providerCredentials[]`;GitHub PR、UniDesk SSH passthrough 等 agent shell/tool 授权使用装配 SPEC 定义的 `toolCredentials[]`,不得混入 `transientEnv`。示例形态: ```json { @@ -116,7 +118,7 @@ Run 的 `executionPolicy.secretScope` 只能包含引用,不包含值。provid ## runner-job transientEnv -`transientEnv` 用于承接调度方生成的短期、单次 runner Job 运行上下文,例如 HWLAB Code Agent 的 device-pod session token 和 API URL。它不是 provider credential、tool credential,也不是 run durable fact。 +`transientEnv` 用于承接调度方生成的短期、单次 runner Job 运行上下文,例如 HWLAB Code Agent 的 device-pod session token、API URL 和非敏感 UniDesk frontend 地址。它不是 provider credential、tool credential,也不是 run durable fact。 规则: @@ -125,7 +127,7 @@ Run 的 `executionPolicy.secretScope` 只能包含引用,不包含值。provid - response、runner job status、event 和 dry-run manifest 只能展示 env name、count 和 `valuesPrinted=false`;dry-run manifest 中的 transient env value 必须显示为 `REDACTED`。 - 正式 Kubernetes Job manifest 会把 value 注入到本次 runner container env;该 token 必须由调度方控制 TTL、权限和业务授权范围。 - AgentRun 不解释 HWLAB device-pod 权限,也不把业务鉴权做成通用 policy;AgentRun 只负责不持久化、不回显、不扩散这类短期 env value。 -- GitHub token、SSH private key、provider API key、registry token 等可复用 credential 不得通过 `transientEnv` 注入;必须先进入装配 SPEC 的 SecretRef 路径。 +- GitHub token、UniDesk SSH client token、SSH private key、provider API key、registry token 等可复用 credential 不得通过 `transientEnv` 注入;必须先进入装配 SPEC 的 SecretRef 路径。 ## 分发路径 @@ -198,6 +200,6 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 | Codex auth/config file projection | 已实现主路径 | backend readiness 检查 `auth.json`/`config.toml` 可读性,缺失时返回 `secret-unavailable`;真实 runner Job 将只读 projection 复制到 writable `CODEX_HOME`。 | | DeepSeek profile SecretRef | 已实现/已通过主闭环 | 已新增 `agentrun-v01-provider-deepseek` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建与 Kubernetes Job projection 已通过主闭环,轮换仍由 Kubernetes 密钥管理流程完成。 | | MiniMax-M3 profile SecretRef | 已实现/待真实主闭环 | 已新增 `agentrun-v01-provider-minimax-m3` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建使用 HWLAB Code Queue 现有 MiniMax API key,轮换仍由 Kubernetes 密钥管理流程完成。 | -| Tool credential SecretRef | 已实现最小 env projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github` 与 `projection.kind=env`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 | +| Tool credential SecretRef | 已实现最小 env projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github`、`tool=unidesk-ssh` 与 `projection.kind=env`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 | | redaction 最小规则 | 已实现主路径 | Secret dry-run 工具、event、Job dry-run 输出、self-test 和真实主闭环均不打印 Secret value;复杂审计按 [spec-v01-validation.md](spec-v01-validation.md) 人工抽查。 | | 外部 secret manager | 未采用 | 如需 Vault/ExternalSecrets/SOPS,后续单独更新规格。 | diff --git a/src/common/validation.ts b/src/common/validation.ts index fd915b4..27e0d94 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -4,6 +4,7 @@ import { AgentRunError } from "./errors.js"; import { backendProfileSpec, backendProfiles, isBackendProfile } from "./backend-profiles.js"; const allowedTenants = new Set(["unidesk", "hwlab"]); +const allowedToolCredentials = ["github", "unidesk-ssh"] as const; export function nowIso(): string { return new Date().toISOString(); @@ -162,7 +163,7 @@ function validateToolCredentials(value: unknown): NonNullable { const item = asRecord(credential, `toolCredentials[${index}]`); const tool = requiredString(item, "tool"); - if (tool !== "github") throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: ["github"] } }); + if (!allowedToolCredentials.includes(tool as (typeof allowedToolCredentials)[number])) throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: [...allowedToolCredentials] } }); const purpose = optionalString(item.purpose); const secretRef = validateSecretRef(asRecord(item.secretRef, `toolCredentials[${index}].secretRef`)); const keys = secretRef.keys ?? []; @@ -174,6 +175,7 @@ function validateToolCredentials(value: unknown): NonNullable { secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] }, projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" }, }]; - const item = await createRunWithCommand(client, { ...context, toolCredentials: githubToolCredentials }, "job smoke", "selftest-job-render", 15_000); + const unideskSshToolCredentials = [{ + tool: "unidesk-ssh", + purpose: "ssh-passthrough", + secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, + projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }, + }]; + const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials]; + const item = await createRunWithCommand(client, { ...context, toolCredentials: combinedToolCredentials }, "job smoke", "selftest-job-render", 15_000); const rendered = renderRunnerJobDryRun({ run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord, commandId: item.commandId, @@ -34,10 +41,24 @@ const selfTest: SelfTestCase = async (context) => { assert.equal((rendered.jobIdentity as { serviceAccountName?: string }).serviceAccountName, "agentrun-v01-runner"); assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0"); assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); + assertRunnerJobUsesToolCredential(rendered, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "REDACTED"); assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_DEVICE_POD_SESSION_TOKEN"]); assertNoSecretLeak(rendered); + await assert.rejects( + () => createRunWithCommand(client, { + ...context, + toolCredentials: [{ + tool: "unidesk-ssh", + purpose: "ssh-passthrough", + secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, + projection: { kind: "env", envName: "UNIDESK_SSH_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }, + }], + }, "bad unidesk ssh projection", "selftest-bad-unidesk-ssh-projection", 15_000), + (error) => error instanceof Error && error.message.includes("unidesk-ssh must project UNIDESK_SSH_CLIENT_TOKEN"), + ); + const deepseekItem = await createRunWithCommand(client, { ...context, backendProfile: "deepseek" }, "deepseek job smoke", "selftest-deepseek-job-render", 15_000); const deepseekRendered = renderRunnerJobDryRun({ run: await client.get(`/api/v1/runs/${deepseekItem.runId}`) as RunRecord, @@ -91,7 +112,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin }); try { const jobClient = new ManagerClient(serverWithKubectl.baseUrl); - const jobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: githubToolCredentials }, "job create smoke", "selftest-job-create", 15_000); + const jobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: combinedToolCredentials }, "job create smoke", "selftest-job-create", 15_000); const created = await jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, { commandId: jobItem.commandId, attemptId: "attempt_selftest_create", @@ -106,22 +127,33 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin { name: "HWLAB_RUNTIME_ENDPOINT_SOURCE", value: "runtime-namespace", sensitive: true }, { name: "HWLAB_RUNTIME_ENDPOINT_LOCKED", value: "1", sensitive: true }, { name: "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", value: "1", sensitive: true }, + { name: "UNIDESK_MAIN_SERVER_IP", value: "https://unidesk.example.test", sensitive: true }, ], }); assert.equal((created as { mutation?: unknown }).mutation, true); assert.equal(((created as JsonRecord).retention as JsonRecord).ttlSecondsAfterFinished, 86_400); - assert.deepEqual((((created as JsonRecord).transientEnv as JsonRecord).names) as string[], ["HWLAB_DEVICE_POD_SESSION_TOKEN", "HWLAB_CLOUD_API_URL", "HWLAB_DEVICE_POD_API_URL", "HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_SOURCE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME"]); + assert.deepEqual((((created as JsonRecord).transientEnv as JsonRecord).names) as string[], ["HWLAB_DEVICE_POD_SESSION_TOKEN", "HWLAB_CLOUD_API_URL", "HWLAB_DEVICE_POD_API_URL", "HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_SOURCE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", "UNIDESK_MAIN_SERVER_IP"]); const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord; assert.equal((manifest.spec as JsonRecord).ttlSecondsAfterFinished, 86_400); assert.equal(runnerEnvValue(manifest, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "test-token-material"); assert.equal(runnerEnvValue(manifest, "HWLAB_CLOUD_API_URL"), "http://cloud.test"); assert.equal(runnerEnvValue(manifest, "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME"), "1"); + assert.equal(runnerEnvValue(manifest, "UNIDESK_MAIN_SERVER_IP"), "https://unidesk.example.test"); assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); + assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); assertNoSecretLeak(created); + await assert.rejects( + () => jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, { + commandId: jobItem.commandId, + attemptId: "attempt_selftest_bad_unidesk_ssh_transient", + transientEnv: [{ name: "UNIDESK_SSH_CLIENT_TOKEN", value: "test-unidesk-ssh-client-token", sensitive: true }], + }), + (error) => error instanceof Error && error.message.includes("must use tool/provider credential assembly instead"), + ); } finally { await new Promise((resolve) => serverWithKubectl.server.close(() => resolve())); } - return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env"] }; + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-transient-env-denied"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } @@ -157,7 +189,14 @@ function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string const summary = rendered.toolCredentials as JsonRecord; assert.equal(summary.valuesPrinted, false); - assert.equal(summary.count, 1); + assert.ok(Number(summary.count) >= 1); + const items = summary.items as JsonRecord[]; + const summaryEntry = items.find((item) => { + const projection = item.projection as JsonRecord; + return item.name === secretName && projection.envName === envName && projection.secretKey === secretKey; + }); + assert.ok(summaryEntry, `${envName} tool credential summary should include its SecretRef and projection`); + assert.equal(summaryEntry.valuesPrinted, false); } function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void {