From 159b99e763894eeea63c8840d7be3c97416a7d59 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 00:22:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20runner=20tool=20cr?= =?UTF-8?q?edential=20=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/container/Containerfile | 2 +- docs/reference/spec-v01-runtime-assembly.md | 2 +- .../reference/spec-v01-secret-distribution.md | 1 + docs/reference/spec-v01-services.md | 2 +- src/common/types.ts | 10 ++++ src/common/validation.ts | 33 +++++++++++ src/mgr/kubernetes-runner-job.ts | 23 +++++++- src/runner/k8s-job.ts | 59 +++++++++++++++++-- src/selftest/cases/20-runner-k8s-job.ts | 35 ++++++++++- src/selftest/harness.ts | 10 +++- 10 files changed, 163 insertions(+), 14 deletions(-) diff --git a/deploy/container/Containerfile b/deploy/container/Containerfile index b86163f..d5f8404 100644 --- a/deploy/container/Containerfile +++ b/deploy/container/Containerfile @@ -9,7 +9,7 @@ ENV PORT=8080 ENV AGENTRUN_CODEX_COMMAND=/app/node_modules/.bin/codex RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \ - apk add --no-cache ca-certificates git kubectl nodejs openssh-client + apk add --no-cache ca-certificates curl git github-cli kubectl nodejs openssh-client COPY package.json tsconfig.json ./ RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \ diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index 6d65489..f97ee0e 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -187,4 +187,4 @@ HWLAB v0.2 原有 Code Agent 已经验证了 profile、session、workspace 和 S | `ProfileRef` | 已实现/已通过主闭环 | `codex` 与 `deepseek` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证。 | | `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` | 已定义/待实现 | GitHub PR 等 agent shell/tool 授权必须通过装配 SPEC 的 SecretRef 进入 runner;当前发现缺 `gh`/token 时按装配缺口处理,不用 `transientEnv` 绕过。 | +| `toolCredentials` | 已实现最小 env projection | GitHub PR 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 先支持 `tool=github`、`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 dc3e241..570be94 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -187,5 +187,6 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 | Codex Secret dry-run 工具 | 已实现 | `./scripts/agentrun secrets codex render --dry-run` 只输出 Secret 创建计划、hash 和 redacted manifest 摘要,不执行 apply。 | | 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 密钥管理流程完成。 | +| 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 元数据,不输出值。 | | 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/docs/reference/spec-v01-services.md b/docs/reference/spec-v01-services.md index 36a92e0..c98074a 100644 --- a/docs/reference/spec-v01-services.md +++ b/docs/reference/spec-v01-services.md @@ -84,7 +84,7 @@ Runner inbound API 只允许本地或私有诊断,不作为业务客户端入 | AgentRun CLI | CLI/Job 工具 | 保留,P0 | JSON 输出、短返回、run/command/event/runner/backend 操作入口。 | `spec-v01-cli.md` | | Postgres durable store | 稳定外部服务 | 保留,P0 | 使用 `agentrun-v01-postgres` 保存 runs、commands、events、runners、backends、leases 和 migration ledger;不使用 file/sqlite 作为 v0.1 durable store。 | `spec-v01-postgres.md` | | Secret distribution | 系统能力 | 保留,P0 | Provider credential 只通过 Kubernetes SecretRef、ServiceAccount/RBAC 和 runner env/file projection 分发;Codex 测试凭据使用 `~/.codex/auth.json` 与 `~/.codex/config.toml` 生成 Secret projection;source、GitOps、logs 和 events 不保存明文。 | `spec-v01-secret-distribution.md` | -| RuntimeAssembly | 系统能力 | 保留,P0 规格 | runner/backend 启动前的装配 SPEC:`BackendImageRef`、`ProfileRef`、`SessionRef`、Git-only `ResourceBundleRef` 和 tool credential SecretRef scope;ProfileRef、SessionRef 和 ResourceBundleRef 已具备 v0.1 最小实现,tool credential 待实现。 | `spec-v01-runtime-assembly.md` | +| RuntimeAssembly | 系统能力 | 保留,P0 规格 | runner/backend 启动前的装配 SPEC:`BackendImageRef`、`ProfileRef`、`SessionRef`、Git-only `ResourceBundleRef` 和 tool credential SecretRef scope;ProfileRef、SessionRef、ResourceBundleRef 和 GitHub tool credential env projection 已具备 v0.1 最小实现。 | `spec-v01-runtime-assembly.md` | | HWLAB 手动调度接入 | canary 集成目标 | 保留,P0 规格 | HWLAB `hwlab-cloud-api` 显式创建 run/command 并调用 runner Job API;AgentRun 提供 durable facts、events、cancel、bundle 和 session 能力。 | `spec-v01-hwlab-manual-dispatch.md` | | Tenant policy boundary | Run schema 合同 | 保留,P0 | 作为 `Run` 的必填字段和最小校验存在,不做独立 policy engine;tenant 的业务授权仍由 UniDesk/HWLAB 判定。 | 并入 `spec-v01-agentrun-mgr.md` | | Observability | 最小事件/日志合同 | 保留,P1 子项 | 作为 manager/runner 的 event、terminal status、failureKind、logPath 和 redaction 最小合同,不拆独立观测系统。 | 并入 `spec-v01-agentrun-mgr.md`、`spec-v01-agentrun-runner.md` | diff --git a/src/common/types.ts b/src/common/types.ts index a747e70..abb6a2c 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -68,6 +68,16 @@ export interface ExecutionPolicy extends JsonRecord { profile: BackendProfile | string; secretRef: SecretRef; }>; + toolCredentials?: Array<{ + tool: string; + purpose?: string; + secretRef: SecretRef; + projection: { + kind: "env"; + envName: string; + secretKey?: string; + }; + }>; allowCredentialEcho?: false; }; } diff --git a/src/common/validation.ts b/src/common/validation.ts index 67d7f04..41b6560 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -122,8 +122,10 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { if (!keys.includes(requiredKey)) throw new AgentRunError("schema-invalid", `provider credential ${profile} secretRef.keys must include ${requiredKey}`, { httpStatus: 400 }); } } + const toolCredentials = validateToolCredentials(secretScope.toolCredentials); const secretScopeResult: ExecutionPolicy["secretScope"] = { allowCredentialEcho: false }; if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable; + if (toolCredentials.length > 0) secretScopeResult.toolCredentials = toolCredentials; return { sandbox: requiredString(record, "sandbox"), approval: requiredString(record, "approval"), @@ -133,6 +135,37 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { }; } +function validateToolCredentials(value: unknown): NonNullable { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "toolCredentials must be an array", { httpStatus: 400 }); + if (value.length > 8) throw new AgentRunError("schema-invalid", "toolCredentials must contain at most 8 entries", { httpStatus: 400 }); + const seen = new Set(); + return value.map((credential, index) => { + 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"] } }); + const purpose = optionalString(item.purpose); + const secretRef = validateSecretRef(asRecord(item.secretRef, `toolCredentials[${index}].secretRef`)); + const keys = secretRef.keys ?? []; + if (keys.length === 0) throw new AgentRunError("schema-invalid", `tool credential ${tool} secretRef.keys must not be empty`, { httpStatus: 400 }); + const projection = asRecord(item.projection, `toolCredentials[${index}].projection`); + const kind = requiredString(projection, "kind"); + if (kind !== "env") throw new AgentRunError("schema-invalid", "toolCredentials[].projection.kind must be env in v0.1", { httpStatus: 400 }); + const envName = requiredString(projection, "envName"); + validateEnvName(envName, `toolCredentials[${index}].projection.envName`); + const secretKey = optionalString(projection.secretKey) ?? keys[0]; + if (!secretKey || !keys.includes(secretKey)) throw new AgentRunError("schema-invalid", `tool credential ${tool} projection.secretKey must be included in secretRef.keys`, { httpStatus: 400 }); + const identity = `${tool}:${purpose ?? ""}:${envName}`; + if (seen.has(identity)) throw new AgentRunError("schema-invalid", `tool credential projection ${identity} is duplicated`, { httpStatus: 400 }); + seen.add(identity); + return { tool, ...(purpose ? { purpose } : {}), secretRef, projection: { kind: "env", envName, secretKey } }; + }); +} + +export function validateEnvName(name: string, fieldName: string): void { + if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be an uppercase env name`, { httpStatus: 400 }); +} + function validateSecretRef(record: JsonRecord): SecretRef { const name = requiredString(record, "name"); const result: SecretRef = { name }; diff --git a/src/mgr/kubernetes-runner-job.ts b/src/mgr/kubernetes-runner-job.ts index a165d8b..575592b 100644 --- a/src/mgr/kubernetes-runner-job.ts +++ b/src/mgr/kubernetes-runner-job.ts @@ -4,7 +4,7 @@ import { redactJson, redactText } from "../common/redaction.js"; import { isTerminalCommandState, isTerminalRunStatus, summarizeResourceBundleRef, summarizeSessionRef } from "./store.js"; import type { AgentRunStore } from "./store.js"; import type { JsonRecord } from "../common/types.js"; -import { stableHash } from "../common/validation.js"; +import { stableHash, validateEnvName } from "../common/validation.js"; import { renderRunnerJobManifest } from "../runner/k8s-job.js"; import type { RunnerTransientEnv } from "../runner/k8s-job.js"; @@ -106,6 +106,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore; logPath: `kubectl -n ${render.namespace} logs job/${render.jobName}`, }, secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })), + toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace), transientEnv: summarizeTransientEnv(transientEnv), retention: { ttlSecondsAfterFinished: render.ttlSecondsAfterFinished, @@ -148,6 +149,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore; jobName: saved.jobName, idempotencyKey: idempotencyKey ? "present" : null, transientEnv: summarizeTransientEnv(transientEnv), + toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace), sessionRef: summarizeSessionRef(run.sessionRef ?? null), resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? null), }); @@ -163,7 +165,8 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] { if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new AgentRunError("schema-invalid", `transientEnv[${index}] must be an object`, { httpStatus: 400 }); const record = entry as JsonRecord; const name = stringField(record, "name"); - if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `transientEnv[${index}].name must be an uppercase env name`, { httpStatus: 400 }); + validateEnvName(name, `transientEnv[${index}].name`); + if (name === "GH_TOKEN" || name === "GITHUB_TOKEN" || name === "OPENAI_API_KEY" || name === "CODEX_API_KEY") throw new AgentRunError("tenant-policy-denied", `transientEnv ${name} must use tool/provider credential assembly instead`, { httpStatus: 403 }); if (seen.has(name)) throw new AgentRunError("schema-invalid", `transientEnv name ${name} is duplicated`, { httpStatus: 400 }); seen.add(name); const rawValue = record.value; @@ -173,6 +176,22 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] { }); } +function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; envName: string; secretKey: string }>, namespace: string): JsonRecord { + return { + count: items.length, + items: items.map((item) => ({ + tool: item.tool, + purpose: item.purpose, + name: item.secretRef.name, + namespace: item.secretRef.namespace ?? namespace, + keys: item.secretRef.keys ?? [], + projection: { kind: "env", envName: item.envName, secretKey: item.secretKey }, + valuesPrinted: false, + })), + valuesPrinted: false, + }; +} + function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord { return { count: items.length, diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index f3cad5e..a60a1a0 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -33,6 +33,14 @@ interface CredentialProjection { projectionMountPath: string; } +interface ToolCredentialProjection { + tool: string; + purpose: string | null; + secretRef: SecretRef; + envName: string; + secretKey: string; +} + export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord { const render = renderRunnerJobManifest({ ...options, dryRun: true }); const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []); @@ -56,6 +64,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco sourceCommit: render.sourceCommit, }, secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })), + toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace), transientEnv: summarizeTransientEnv(options.transientEnv ?? []), retention: { ttlSecondsAfterFinished: render.ttlSecondsAfterFinished, @@ -69,7 +78,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco }; } -export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; warnings: string[]; ttlSecondsAfterFinished: number } { +export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; warnings: string[]; ttlSecondsAfterFinished: number } { const namespace = options.namespace ?? "agentrun-v01"; const attemptId = options.attemptId ?? `attempt_${Date.now().toString(36)}`; const runnerId = options.runnerId ?? `runner_${shortHash(`${options.run.id}:${attemptId}:${options.commandId}`)}`; @@ -78,9 +87,10 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani const ttlSecondsAfterFinished = options.ttlSecondsAfterFinished ?? 86_400; const jobName = `agentrun-v01-runner-${shortDnsHash(options.run.id, attemptId)}`; const secretRefs = credentialProjections(options.run, namespace); + const toolCredentials = toolCredentialProjections(options.run, namespace); const warnings: string[] = []; if (secretRefs.length === 0) warnings.push("run executionPolicy.secretScope 未声明 provider SecretRef;runner 将按 secret-unavailable 上报,而不会降级直连外部凭据"); - const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs }); + const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs, toolCredentials }); const manifest: JsonRecord = { apiVersion: "batch/v1", kind: "Job", @@ -136,10 +146,10 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani }, }, }; - return { manifest, namespace, jobName, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, warnings, ttlSecondsAfterFinished }; + return { manifest, namespace, jobName, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, toolCredentials, warnings, ttlSecondsAfterFinished }; } -function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[] }): JsonRecord[] { +function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[] }): JsonRecord[] { const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile); const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile); return [ @@ -161,10 +171,23 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string { name: "HOME", value: "/home/agentrun" }, { name: "CODEX_HOME", value: codexHome }, ...(selectedSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: selectedSecret.projectionMountPath }] : []), + ...toolCredentialEnvVars(context.toolCredentials), ...transientEnvVars(options.transientEnv ?? []), ]; } +function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] { + return items.map((item) => ({ + name: item.envName, + valueFrom: { + secretKeyRef: { + name: item.secretRef.name, + key: item.secretKey, + }, + }, + })); +} + function transientEnvVars(items: RunnerTransientEnv[]): JsonRecord[] { return items.map((item) => ({ name: item.name, value: item.value })); } @@ -177,6 +200,22 @@ function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord { }; } +function summarizeToolCredentials(items: ToolCredentialProjection[], namespace: string): JsonRecord { + return { + count: items.length, + items: items.map((item) => ({ + tool: item.tool, + purpose: item.purpose, + name: item.secretRef.name, + namespace: item.secretRef.namespace ?? namespace, + keys: item.secretRef.keys ?? [], + projection: { kind: "env", envName: item.envName, secretKey: item.secretKey }, + valuesPrinted: false, + })), + valuesPrinted: false, + }; +} + function redactTransientEnvInManifest(manifest: JsonRecord, items: RunnerTransientEnv[]): JsonRecord { if (items.length === 0) return manifest; const names = new Set(items.map((item) => item.name)); @@ -206,6 +245,18 @@ function credentialProjections(run: RunRecord, namespace: string): CredentialPro })); } +function toolCredentialProjections(run: RunRecord, namespace: string): ToolCredentialProjection[] { + const policy: ExecutionPolicy = run.executionPolicy; + const credentials = policy.secretScope.toolCredentials ?? []; + return credentials.map((item) => ({ + tool: item.tool, + purpose: item.purpose ?? null, + secretRef: item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace }, + envName: item.projection.envName, + secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName, + })); +} + function secretVolume(item: CredentialProjection): JsonRecord { const secret: JsonRecord = { secretName: item.secretRef.name, diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 937e043..b330965 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -12,7 +12,13 @@ const selfTest: SelfTestCase = async (context) => { const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: new MemoryAgentRunStore() }); try { const client = new ManagerClient(server.baseUrl); - const item = await createRunWithCommand(client, context, "job smoke", "selftest-job-render", 15_000); + const githubToolCredentials = [{ + tool: "github", + purpose: "pull-request", + 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 rendered = renderRunnerJobDryRun({ run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord, commandId: item.commandId, @@ -27,6 +33,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(((rendered.retention as JsonRecord).ttlSecondsAfterFinished), 86_400); 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"); 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); @@ -70,7 +77,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin }); try { const jobClient = new ManagerClient(serverWithKubectl.baseUrl); - const jobItem = await createRunWithCommand(jobClient, context, "job create smoke", "selftest-job-create", 15_000); + const jobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: githubToolCredentials }, "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", @@ -86,11 +93,12 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin 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"); + assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); assertNoSecretLeak(created); } 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-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env"] }; + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } @@ -108,6 +116,27 @@ function runnerEnvValue(manifest: JsonRecord, name: string): unknown { return env.find((item) => item.name === name)?.value; } +function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string, secretName: string, secretKey: string): void { + const manifest = rendered.manifest as JsonRecord; + const spec = manifest.spec as JsonRecord; + const template = spec.template as JsonRecord; + const podSpec = template.spec as JsonRecord; + const containers = podSpec.containers as JsonRecord[]; + const runner = containers[0] as JsonRecord; + const env = runner.env as JsonRecord[]; + const entry = env.find((item) => item.name === envName) as JsonRecord | undefined; + assert.ok(entry, `${envName} env should be projected from a SecretRef`); + assert.equal(entry.value, undefined); + const valueFrom = entry.valueFrom as JsonRecord; + const secretKeyRef = valueFrom.secretKeyRef as JsonRecord; + assert.equal(secretKeyRef.name, secretName); + assert.equal(secretKeyRef.key, secretKey); + + const summary = rendered.toolCredentials as JsonRecord; + assert.equal(summary.valuesPrinted, false); + assert.equal(summary.count, 1); +} + function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void { const spec = manifest.spec as JsonRecord; const template = spec.template as JsonRecord; diff --git a/src/selftest/harness.ts b/src/selftest/harness.ts index fbf4d2b..b51b873 100644 --- a/src/selftest/harness.ts +++ b/src/selftest/harness.ts @@ -25,6 +25,8 @@ export interface SelfTestResult { export type SelfTestCase = (context: SelfTestContext) => Promise | SelfTestResult; +type SelfTestRunContext = Pick & Partial> & { backendProfile?: BackendProfile; includeOnlyProfile?: BackendProfile; toolCredentials?: JsonRecord[] }; + export async function createSelfTestContext(root: string): Promise { const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-")); const codexHome = path.join(tmp, "codex-home"); @@ -52,7 +54,7 @@ export async function createSelfTestContext(root: string): Promise & Partial> & { backendProfile?: BackendProfile; includeOnlyProfile?: BackendProfile }, prompt: string, idempotencyKey: string, timeoutMs: number): Promise<{ runId: string; commandId: string }> { +export async function createRunWithCommand(client: ManagerClient, context: SelfTestRunContext, prompt: string, idempotencyKey: string, timeoutMs: number): Promise<{ runId: string; commandId: string }> { const backendProfile = context.backendProfile ?? "codex"; const run = await client.post("/api/v1/runs", { tenantId: "unidesk", @@ -65,7 +67,7 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick< approval: "never", timeoutMs, network: "default", - secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile) }, + secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile), ...toolCredentialScope(context) }, }, traceSink: null, }) as { id: string }; @@ -75,6 +77,10 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick< return { runId: run.id, commandId: command.id }; } +function toolCredentialScope(context: { toolCredentials?: JsonRecord[] }): JsonRecord { + return context.toolCredentials ? { toolCredentials: context.toolCredentials } : {}; +} + function providerCredentials(context: Pick & Partial> & { includeOnlyProfile?: BackendProfile }, backendProfile: BackendProfile): JsonRecord[] { const profiles: BackendProfile[] = context.includeOnlyProfile ? [context.includeOnlyProfile] : [backendProfile]; return profiles.map((profile) => ({