diff --git a/deploy/deploy.json b/deploy/deploy.json index 21a52d0..8c2d0c3 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -34,7 +34,17 @@ "name": "codex-secret-projection", "secretRef": { "name": "agentrun-v01-provider-codex", "keys": ["auth.json", "config.toml"] }, "projectionPath": "/var/run/agentrun/secrets/codex-0", - "runtimeCopyPath": "/home/agentrun/.codex", + "runtimeCopyPath": "/home/agentrun/.codex-codex", + "profile": "codex", + "readOnly": true, + "writableCopy": true + }, + { + "name": "deepseek-secret-projection", + "secretRef": { "name": "agentrun-v01-provider-deepseek", "keys": ["auth.json", "config.toml"] }, + "projectionPath": "/var/run/agentrun/secrets/deepseek-0", + "runtimeCopyPath": "/home/agentrun/.codex-deepseek", + "profile": "deepseek", "readOnly": true, "writableCopy": true } diff --git a/docs/reference/spec-v01-agentrun-mgr.md b/docs/reference/spec-v01-agentrun-mgr.md index 897a2af..3cf127b 100644 --- a/docs/reference/spec-v01-agentrun-mgr.md +++ b/docs/reference/spec-v01-agentrun-mgr.md @@ -110,6 +110,6 @@ POST /api/v1/commands/:commandId/ack | `agentrun-mgr` 服务规格 | 已定义 | 本文为 v0.1 manager 权威。 | | Manager REST API | 已实现/已通过主闭环 | 已有 run、command、event、backends、runner register、claim、lease heartbeat、poll、ack、status、runner Job 创建和 health/readiness 的 HTTP JSON API;真实 runtime 已通过 RESTful API 主闭环。 | | Tenant policy boundary | 已实现最小边界 | v0.1 已做 schema、tenant/backend allowlist、executionPolicy 和 secretScope 结构校验;业务授权仍由 UniDesk/HWLAB 自己判定。 | -| `deepseek` backendProfile allowlist | 已定义/待实现 | 需要扩展 manager validation、backend capability 和 matching SecretRef 校验。 | +| `deepseek` backendProfile allowlist | 已实现/待真实联调 | Manager validation、backend capability 和 matching SecretRef 校验已支持 `deepseek`;真实 runtime 需经 CI/CD 发布后确认 Postgres migration `002_v01_backend_profiles` 应用。 | | Postgres durable adapter | 已实现/已通过主闭环 | live runtime 通过 `DATABASE_URL` 使用 Postgres durable store;memory store 仅用于显式 self-test/dev。见 [spec-v01-postgres.md](spec-v01-postgres.md)。 | | Observability 最小合同 | 已实现主路径 | events append-only、terminal status、failureKind、health/readiness store 状态、runner claim/lease/backend events 和 Secret/DSN redaction 已进入 manager;集中 trace 和部署级观测仍属后续工作。 | diff --git a/docs/reference/spec-v01-agentrun-runner.md b/docs/reference/spec-v01-agentrun-runner.md index a480bb4..2cbd075 100644 --- a/docs/reference/spec-v01-agentrun-runner.md +++ b/docs/reference/spec-v01-agentrun-runner.md @@ -112,4 +112,4 @@ Runner 日志必须实时 flush 到文件或 pod log,CLI 启动 runner 时必 | host process runner | 已实现 | `runner start` 和 `src/runner/main.ts` 进入同一套 `runOnce`,可通过 manager register/claim/poll/report 执行自测试。 | | claim/lease/report client | 已实现 | 已拆出 runner manager API client,覆盖 register、claim、lease heartbeat、poll command、ack、append event 和 terminal status;live runtime 通过 manager 写入 Postgres durable store。 | | runner redaction | 已实现主路径 | runner/backend event 和 Job 输出使用 redaction;复杂审计仍按 [spec-v01-validation.md](spec-v01-validation.md) 的人工验收抽查。 | -| `deepseek` profile runner selection | 已定义/待实现 | 需要按 run `backendProfile` 选择 matching SecretRef、projection、`CODEX_HOME` 和 backend metadata。 | +| `deepseek` profile runner selection | 已实现/待真实联调 | Runner Job 和 host runner 已按 run `backendProfile` 选择 matching SecretRef、projection、`CODEX_HOME` 和 backend metadata;真实 Kubernetes Job 联调需发布后执行。 | diff --git a/docs/reference/spec-v01-backend-adapter.md b/docs/reference/spec-v01-backend-adapter.md index 2fa4ad7..aaa502e 100644 --- a/docs/reference/spec-v01-backend-adapter.md +++ b/docs/reference/spec-v01-backend-adapter.md @@ -35,7 +35,7 @@ Adapter 输入必须来自 manager 保存的 run/command 和 Kubernetes Secret p | profile | backendKind | protocol | transport | command | v0.1 状态 | | --- | --- | --- | --- | --- | --- | | `codex` | `codex-app-server-stdio` | `codex-app-server-jsonrpc-stdio` | `stdio` | `codex app-server --listen stdio://` | 已有主闭环,必须保持默认兼容。 | -| `deepseek` | `codex-app-server-stdio` | `codex-app-server-jsonrpc-stdio` | `stdio` | `codex app-server --listen stdio://` | 待实现 profile;实现后必须用独立 SecretRef 和 `CODEX_HOME` 真实联调。 | +| `deepseek` | `codex-app-server-stdio` | `codex-app-server-jsonrpc-stdio` | `stdio` | `codex app-server --listen stdio://` | 已实现 profile;必须用独立 SecretRef 和 profile-scoped `CODEX_HOME` 完成真实联调。 | Registry 只表达能力和选择边界,不读取 Secret 值。Manager 负责校验 `backendProfile` 是否在 allowlist 内,并校验 `executionPolicy.secretScope.providerCredentials` 是否存在匹配 profile 的 SecretRef;runner 只为当前 run 选择的 profile 准备 Secret projection 和 runtime home。 @@ -101,8 +101,8 @@ Adapter 必须把 backend 错误映射为稳定 failureKind: | 规格项 | 状态 | 说明 | | --- | --- | --- | | Backend adapter 合同 | 已定义 | 本文为 v0.1 adapter 权威。 | -| 通用 adapter 模块 | 已实现最小形态/待扩展 profile | `src/backend/adapter.ts` 作为 runner 进程内 adapter 入口;当前已路由真实 `codex` profile,仍需扩展 `deepseek` profile 但不应复制第二套 stdio 协议实现。 | +| 通用 adapter 模块 | 已实现 profile 形态 | `src/backend/adapter.ts` 作为 runner 进程内 adapter 入口;`codex` 与 `deepseek` 均路由到同一 Codex stdio backend kind,不复制第二套协议实现。 | | event normalization | 已实现主路径 | Codex backend 已把 backend_status、assistant_message、tool_call、command_output、error 和 terminal_status 归一化为 manager events;复杂事件审计按人工验收抽查。 | | failure mapping | 已实现主路径 | Codex backend 已覆盖 missing secret、auth/rate/availability、protocol、JSON parse、invalid response、spawn、timeout 和 cancel 分类;真实负向场景按 [spec-v01-validation.md](spec-v01-validation.md) T7 手动验收。 | -| `deepseek` profile | 已定义/待实现 | v0.1 要求作为同一 Codex stdio backend kind 的 profile 进入 registry、validation、runner secret selection 和综合联调。 | +| `deepseek` profile | 已实现/待真实联调 | 已进入 registry、validation、runner Secret selection、backend_status metadata、CLI secret render 和 fake stdio 自测试;真实综合联调按 [spec-v01-validation.md](spec-v01-validation.md) T8 执行。 | | 多 backend 路由 | Deferred | 跨 backend kind 的自动路由和 scheduler capacity selection 不进入 v0.1。 | diff --git a/docs/reference/spec-v01-backend-codex.md b/docs/reference/spec-v01-backend-codex.md index 2d71a33..6ba2cb4 100644 --- a/docs/reference/spec-v01-backend-codex.md +++ b/docs/reference/spec-v01-backend-codex.md @@ -48,7 +48,7 @@ Profile 切换规则: - `backendProfile` 是 run 的显式字段,manager 不得静默改写。 - runner/backend 只读取与 `backendProfile` 同名的 provider credential;缺失则 `secret-unavailable`。 -- 每次 run 必须使用 profile-scoped writable `CODEX_HOME`。Kubernetes Job 可以把选中 profile 的 Secret projection 复制到该 Job 独占的 `/home/agentrun/.codex`;host process 或复用进程必须使用 run/profile 独占目录,避免 `codex` 与 `deepseek` 互相污染。 +- 每次 run 必须使用 profile-scoped writable `CODEX_HOME`。Kubernetes Job 默认把选中 profile 的 Secret projection 复制到该 Job 独占的 `/home/agentrun/.codex-`;host process 或复用进程必须使用 run/profile 独占目录,避免 `codex` 与 `deepseek` 互相污染。 - `deepseek` 不得 fallback 到 `codex` Secret、模型或 upstream;`codex` 也不得读取 `deepseek` Secret。 - command payload 中显式提供 model 时可以透传给 Codex turn;未显式提供时以 profile `config.toml` 为 authority,不在 adapter 中写死默认模型。 @@ -130,5 +130,5 @@ Run 的 `executionPolicy.secretScope` 应引用与 `backendProfile` 匹配的 pr | Codex adapter | 已实现/已通过主闭环 | 当前代码已实现受控 `codex app-server --listen stdio://`、`initialize`/`thread/start`/`thread/resume`/`turn/start` response 校验、stderr 有界诊断、spawn/JSON parse/response invalid/timeout/provider 5xx availability failureKind,以及包含 retry error notification 的 fake app-server 自测试。 | | 错误可观测与脱敏 | 已实现主路径 | child env、cwd、workspace 和 Codex home 只输出摘要;stderr tail 有界且标记截断;事件和 failure 统一走 redaction。 | | 真实 provider turn | 已通过主闭环 | 真实 Codex provider turn 已经通过 RESTful API 和 CLI 综合联调;每次发布仍按 [spec-v01-validation.md](spec-v01-validation.md) 手动复验。 | -| `deepseek` profile | 已定义/待实现 | 需要作为同一 Codex stdio backend kind 的 profile 实现,使用 `agentrun-v01-provider-deepseek` 和独立 `CODEX_HOME` 完成真实联调。 | +| `deepseek` profile | 已实现/待真实联调 | 代码已支持 `agentrun-v01-provider-deepseek`、独立 `CODEX_HOME`、同一 `codex app-server --listen stdio://` 协议和 profile metadata;真实联调需在发布后用 Kubernetes SecretRef 执行。 | | hostPath `~/.codex` | 不采用 | 只能通过 Kubernetes Secret projection 注入。 | diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index c651bce..88f2a46 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -93,4 +93,4 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 | CLI 调 manager REST | 已实现 | CLI 通过 `ManagerClient` 调 manager REST;自测试可用内存 manager,综合联调必须指向真实 `agentrun-v01` manager。 | | runner start/job | 已实现 | `runner start` 可执行 host process runner;`runner job --dry-run` 可渲染 Kubernetes Job JSON;`runner job` 正式路径通过 manager REST 创建 Kubernetes Job 并快速返回 job identity、SecretRef、retention 和轮询命令。 | | CLI 测试规格 | 已定义/已验证主闭环 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md);每次发布仍按手动交互验收复跑。 | -| `deepseek` profile CLI | 已定义/待实现 | 需要扩展 secret render、backends list、run create examples 和 runner start/job 的 profile 可见性。 | +| `deepseek` profile CLI | 已实现/待真实联调 | `secrets codex render --profile deepseek`、`backends list`、`runner start --backend` 和 JSON 错误可见性已实现并通过 CLI smoke;真实 CLI 联调需发布后执行。 | diff --git a/docs/reference/spec-v01-postgres.md b/docs/reference/spec-v01-postgres.md index 223a072..a32777c 100644 --- a/docs/reference/spec-v01-postgres.md +++ b/docs/reference/spec-v01-postgres.md @@ -74,7 +74,7 @@ Secret 名称和 key 可以在实现时按 Kubernetes 命名限制微调,但 | --- | --- | --- | | Postgres durable store 规格 | 已定义 | 本文为 v0.1 存储权威。 | | StatefulSet/Service/PVC | 已实现/已通过主闭环 | `agentrun-v01-postgres` StatefulSet、Service 和 PVC 已由 GitOps runtime 提供,作为 `agentrun-v01` durable store。 | -| migration ledger | 已实现/已通过主闭环 | `agentrun-mgr` 启动 Postgres adapter 时幂等创建 `agentrun_schema_migrations` 并记录 migration id/checksum;readiness 必须显示 migration ready。 | +| migration ledger | 已实现/已通过主闭环 | `agentrun-mgr` 启动 Postgres adapter 时幂等创建 `agentrun_schema_migrations` 并记录 migration id/checksum;当前最新 migration 为 `002_v01_backend_profiles`,用于 upsert `codex`/`deepseek` backend capability;readiness 必须显示 migration ready。 | | manager Postgres adapter | 已实现/已通过主闭环 | `agentrun-mgr` 通过 `DATABASE_URL` 启用 Postgres adapter,持久化 runs、commands、events、runners、backends 和 leases;缺少 `DATABASE_URL` 时 live runtime fail fast,memory 只允许显式 self-test/dev。 | | health/readiness store 状态 | 已实现 | health/readiness 返回 adapter、reachable、migrationReady、migrationId、failureKind 和 redacted Secret 状态,不输出 DSN 明文。 | | file/sqlite durable store | 不采用 | 只可用于临时本地测试,不作为 v0.1 runtime truth。 | diff --git a/docs/reference/spec-v01-secret-distribution.md b/docs/reference/spec-v01-secret-distribution.md index ebd3b89..7722c31 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -54,9 +54,9 @@ | Secret key | `auth.json`,来自 `~/.codex/auth.json` | | Secret key | `config.toml`,来自 `~/.codex/config.toml` | | Projection path | 只读 Secret projection 挂到 `/var/run/agentrun/secrets/-/auth.json` 和 `config.toml`;该路径只作为 credential source。 | -| Runtime config path | runner 启动时把当前 `backendProfile` 授权的 Secret projection 复制到 writable `CODEX_HOME`,Kubernetes Job 默认可以使用该 Job 独占的 `/home/agentrun/.codex/auth.json` 和 `config.toml`;复用进程必须使用 run/profile 独占目录。 | +| Runtime config path | runner 启动时把当前 `backendProfile` 授权的 Secret projection 复制到 writable `CODEX_HOME`,Kubernetes Job 默认使用该 Job 独占的 `/home/agentrun/.codex-/auth.json` 和 `config.toml`;复用进程必须使用 run/profile 独占目录。 | | Projection mode | 只读,建议 `0400` 或等价最小权限 | -| Runtime env | `HOME=/home/agentrun`,`CODEX_HOME=/home/agentrun/.codex`,`AGENTRUN_CODEX_SECRET_HOME=`;不得 fallback 到节点宿主机 home。 | +| Runtime env | `HOME=/home/agentrun`,`CODEX_HOME=/home/agentrun/.codex-`,`AGENTRUN_CODEX_SECRET_HOME=`;不得 fallback 到节点宿主机 home。 | Secret 创建和轮换必须通过 Kubernetes 密钥管理完成。`deploy/deploy.json` 只写 SecretRef 名称、key 和 mount intent;`v0.1-gitops` rendered manifests 只引用 Secret,不包含 Secret data。 @@ -169,6 +169,6 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 | Kubernetes SecretRef 注入 | 已实现/已通过主闭环 | runner Job dry-run 和正式 Job 创建路径已按 run `executionPolicy.secretScope.providerCredentials` 生成 Secret volume projection、writable runtime home 和 `AGENTRUN_CODEX_SECRET_HOME`;真实 Secret 与 Codex turn 已通过主闭环。 | | 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、Job projection、profile 选择和负向 missing-secret 验收。 | +| DeepSeek profile SecretRef | 已实现/待真实联调 | 已新增 `agentrun-v01-provider-deepseek` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建和轮换由 Kubernetes 密钥管理流程完成。 | | 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 a5f6f5e..763864c 100644 --- a/docs/reference/spec-v01-services.md +++ b/docs/reference/spec-v01-services.md @@ -183,5 +183,5 @@ Manager 负责校验、保存和返回这些字段;runner 只能消费已保 | `agentrun-mgr` 实现 | 已实现/已通过主闭环 | 已有 REST API、Postgres durable store、migration ledger、runner claim/lease/report、health/readiness 和 self-test memory 模式;真实 `agentrun-v01` runtime 已通过 Postgres/GitOps/readiness 和 run lifecycle 主闭环。 | | `agentrun-runner` 实现 | 已实现/已通过主闭环 | host process runner 与 Kubernetes Job 共用 `runOnce`,runner 通过 manager API claim/poll/report,不直连 Postgres;真实 Kubernetes Job 已完成 Codex turn。 | | `codex` profile | 已实现/已通过主闭环 | Codex app-server stdio backend 已有协议、失败分类、脱敏和 fake self-test;真实 Codex provider turn 已通过 RESTful API 与 CLI 主闭环。 | -| `deepseek` profile | 已定义/待实现 | 规格要求 DeepSeek 作为同一 Codex stdio backend kind 的 profile/config/SecretRef 选择进入 v0.1;实现和真实联调尚未完成。 | +| `deepseek` profile | 已实现/待真实联调 | 代码已支持 DeepSeek 作为同一 Codex stdio backend kind 的 profile/config/SecretRef 选择;自测试覆盖 registry、runner Secret 选择、fake stdio turn 和无 fallback,真实 `agentrun-v01` 联调需在 CI/CD 发布后执行。 | | 自动 scheduler | Deferred | 不作为 `v0.1` 第一阶段验收目标。 | diff --git a/docs/reference/spec-v01-validation.md b/docs/reference/spec-v01-validation.md index a7618df..385384f 100644 --- a/docs/reference/spec-v01-validation.md +++ b/docs/reference/spec-v01-validation.md @@ -166,5 +166,5 @@ T8 是涉及 backend profile 变更时的综合联调标准;不涉及 backend | CLI 交互联调标准 | 已定义 | 必须只使用正式 CLI,验证真实 run 生命周期和可观测输出。 | | RESTful API 交互联调标准 | 已定义 | 必须直连真实 manager HTTP JSON API,验证服务合同和 durable facts。 | | 真实主闭环 | 已通过 | 当前 v0.1 已通过真实 Tekton/Argo、Postgres、SecretRef、Kubernetes runner Job、Codex stdio turn、RESTful API 和 CLI 主闭环;每次发布仍需按本文手动复验。 | -| `deepseek` profile 切换验收 | 已定义/待执行 | 实现 deepseek profile 后必须按 T8 做 100% 真实综合联调。 | +| `deepseek` profile 切换验收 | 已定义/待真实执行 | 自测试和 CLI smoke 已覆盖 profile registry、Secret render、fake stdio turn、无 fallback 和结构化错误;PR 合并、CI/CD 发布后仍必须按 T8 做 100% 真实综合联调。 | | mock 作为发布证据 | 不采用 | mock 只能证明自测试通过。 | diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index a10aae4..81492d8 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -8,6 +8,7 @@ import { renderCodexProviderSecretPlan } from "./secret-render.js"; import type { JsonRecord, JsonValue, RunRecord } from "../../src/common/types.js"; import { AgentRunError, errorToJson } from "../../src/common/errors.js"; import type { RunnerOnceOptions } from "../../src/runner/run-once.js"; +import { isBackendProfile } from "../../src/common/backend-profiles.js"; interface ParsedArgs { positional: string[]; @@ -55,9 +56,14 @@ async function dispatch(args: ParsedArgs): Promise { runId, }; const runnerId = optionalFlag(args, "runner-id"); + const backend = optionalFlag(args, "backend"); const codexCommand = optionalFlag(args, "codex-command"); const codexHome = optionalFlag(args, "codex-home") ?? process.env.CODEX_HOME; if (runnerId) options.runnerId = runnerId; + if (backend) { + if (!isBackendProfile(backend)) throw new AgentRunError("schema-invalid", `runner start --backend ${backend} is not supported in v0.1`, { httpStatus: 2 }); + options.backendProfile = backend; + } if (codexCommand) options.codexCommand = codexCommand; if (codexHome) options.codexHome = codexHome; return runOnce(options) as unknown as JsonValue; @@ -112,11 +118,13 @@ async function renderCodexSecret(args: ParsedArgs): Promise { throw new AgentRunError("schema-invalid", "secrets codex render requires --dry-run", { httpStatus: 2 }); } const options: Parameters[0] = { dryRun: true }; + const profile = optionalFlag(args, "profile"); const codexHome = optionalFlag(args, "codex-home"); const authFile = optionalFlag(args, "auth-file"); const configFile = optionalFlag(args, "config-file"); const namespace = optionalFlag(args, "namespace"); const secretName = optionalFlag(args, "secret-name"); + if (profile) options.profile = profile; if (codexHome) options.codexHome = codexHome; if (authFile) options.authFile = authFile; if (configFile) options.configFile = configFile; @@ -188,10 +196,10 @@ function help(): JsonRecord { "runs events --after-seq --limit ", "commands create --type turn --json-file ", "commands show --run-id ", - "runner start --run-id ", + "runner start --run-id [--backend codex|deepseek]", "runner job --run-id --command-id [--image ] [--runner-manager-url ]", "runner job --dry-run --run-id --command-id --image ", - "secrets codex render --dry-run [--codex-home ] [--namespace agentrun-v01] [--secret-name agentrun-v01-provider-codex]", + "secrets codex render --dry-run [--profile codex|deepseek] [--codex-home ] [--namespace agentrun-v01] [--secret-name ]", "backends list", "server start|status", ], diff --git a/scripts/src/gitops-render.ts b/scripts/src/gitops-render.ts index cc4df37..635738e 100644 --- a/scripts/src/gitops-render.ts +++ b/scripts/src/gitops-render.ts @@ -331,7 +331,7 @@ metadata: rules: - apiGroups: [""] resources: ["secrets"] - resourceNames: ["agentrun-v01-provider-codex"] + resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/scripts/src/secret-render.ts b/scripts/src/secret-render.ts index e889b38..fb25062 100644 --- a/scripts/src/secret-render.ts +++ b/scripts/src/secret-render.ts @@ -5,8 +5,10 @@ import os from "node:os"; import path from "node:path"; import type { JsonRecord } from "../../src/common/types.js"; import { AgentRunError } from "../../src/common/errors.js"; +import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js"; export interface CodexSecretRenderOptions { + profile?: string; codexHome?: string; authFile?: string; configFile?: string; @@ -30,7 +32,6 @@ interface SecretSourceFile { } const defaultNamespace = "agentrun-v01"; -const defaultSecretName = "agentrun-v01-provider-codex"; const secretKeys = ["auth.json", "config.toml"] as const; const credentialKeyPattern = /(?:api[_-]?key|token|password|secret|credential|authorization|auth)/iu; @@ -39,8 +40,11 @@ export async function renderCodexProviderSecretPlan(options: CodexSecretRenderOp throw new AgentRunError("schema-invalid", "Codex provider Secret rendering only supports --dry-run in v0.1", { httpStatus: 2 }); } + const profile = nonEmpty(options.profile, "codex"); + if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `profile ${profile} is not supported in v0.1`, { httpStatus: 2 }); + const spec = backendProfileSpec(profile); const namespace = nonEmpty(options.namespace, defaultNamespace); - const secretName = nonEmpty(options.secretName, defaultSecretName); + const secretName = nonEmpty(options.secretName, spec?.defaultSecretName ?? "agentrun-v01-provider-codex"); const codexHome = resolvePath(nonEmpty(options.codexHome, path.join(os.homedir(), ".codex"))); const sources: SecretSourceFile[] = [ { key: "auth.json", path: resolvePath(options.authFile ?? path.join(codexHome, "auth.json")), validate: validateAuthJson }, @@ -76,6 +80,8 @@ export async function renderCodexProviderSecretPlan(options: CodexSecretRenderOp writeAttempted: false, namespace, secretName, + profile, + backendKind: spec?.backendKind ?? "codex-app-server-stdio", keys: [...secretKeys], totalBytes: files.reduce((sum, file) => sum + file.bytes, 0), sha256: hash.digest("hex"), diff --git a/src/backend/adapter.ts b/src/backend/adapter.ts index cb8115c..f64ad04 100644 --- a/src/backend/adapter.ts +++ b/src/backend/adapter.ts @@ -1,5 +1,6 @@ import type { BackendTurnResult, CommandRecord, RunRecord } from "../common/types.js"; import { runCodexStdioTurn, type CodexStdioTurnOptions } from "./codex-stdio.js"; +import { backendProfileSpec } from "../common/backend-profiles.js"; export interface BackendAdapterOptions { codexCommand?: string; @@ -9,11 +10,13 @@ export interface BackendAdapterOptions { } export async function runBackendTurn(run: RunRecord, command: CommandRecord, options: BackendAdapterOptions = {}): Promise { - if (run.backendProfile !== "codex") { + const spec = backendProfileSpec(run.backendProfile); + if (!spec || spec.backendKind !== "codex-app-server-stdio") { return { terminalStatus: "failed", failureKind: "backend-failed", failureMessage: `unsupported backendProfile ${run.backendProfile}`, events: [{ type: "error", payload: { failureKind: "backend-failed", backendProfile: run.backendProfile } }] }; } const prompt = typeof command.payload.prompt === "string" ? command.payload.prompt : JSON.stringify(command.payload); const turnOptions: CodexStdioTurnOptions = { + backendProfile: run.backendProfile, prompt, cwd: typeof run.workspaceRef.path === "string" ? run.workspaceRef.path : process.cwd(), approvalPolicy: run.executionPolicy.approval, diff --git a/src/backend/codex-stdio.ts b/src/backend/codex-stdio.ts index 860fd15..6d1e8af 100644 --- a/src/backend/codex-stdio.ts +++ b/src/backend/codex-stdio.ts @@ -4,8 +4,9 @@ import { accessSync, constants as fsConstants } from "node:fs"; import { chmod, copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; import * as readline from "node:readline"; -import type { BackendEvent, BackendTurnResult, FailureKind, JsonRecord, JsonValue, TerminalStatus } from "../common/types.js"; +import type { BackendEvent, BackendProfile, BackendTurnResult, FailureKind, JsonRecord, JsonValue, TerminalStatus } from "../common/types.js"; import { redactJson, redactText } from "../common/redaction.js"; +import { backendProfileSpec } from "../common/backend-profiles.js"; const codexProtocol = "codex-app-server-jsonrpc-stdio"; const defaultCodexArgs = ["app-server", "--listen", "stdio://"]; @@ -32,6 +33,7 @@ const childEnvSummaryKeys = [ ]; export interface CodexStdioTurnOptions { + backendProfile?: BackendProfile; prompt: string; cwd: string; model?: string; @@ -266,6 +268,7 @@ export async function runCodexStdioTurn(options: CodexStdioTurnOptions): Promise type: "backend_status", payload: { phase: "codex-app-server-starting", + ...backendMetadata(options), protocol: codexProtocol, runtime: runtimeSummary(options, env, codexHome), }, @@ -307,7 +310,7 @@ export async function runCodexStdioTurn(options: CodexStdioTurnOptions): Promise const initializeResult = requireResponseRecord(await client.request("initialize", { clientInfo: { name: "agentrun", title: "AgentRun", version: "0.1.0" }, capabilities: { experimentalApi: true } }, requestTimeoutMs), "initialize"); validateInitializeResponse(initializeResult); client.notify("initialized", {}); - events.push({ type: "backend_status", payload: { phase: "initialize:completed", protocol: codexProtocol } }); + events.push({ type: "backend_status", payload: { phase: "initialize:completed", ...backendMetadata(options), protocol: codexProtocol } }); const threadMethod = options.threadId ? "thread/resume" : "thread/start"; const threadParams: JsonRecord = options.threadId @@ -525,6 +528,17 @@ function runtimeSummary(options: CodexStdioTurnOptions, env: NodeJS.ProcessEnv, }; } +function backendMetadata(options: CodexStdioTurnOptions): JsonRecord { + const profile = options.backendProfile ?? "codex"; + const spec = backendProfileSpec(profile); + return { + backendProfile: profile, + backendKind: spec?.backendKind ?? "codex-app-server-stdio", + protocol: spec?.protocol ?? codexProtocol, + transport: spec?.transport ?? "stdio", + }; +} + function envSummary(env: NodeJS.ProcessEnv): JsonRecord { const keyState: Record = {}; for (const key of childEnvSummaryKeys) keyState[key] = { present: typeof env[key] === "string" && String(env[key]).length > 0 }; diff --git a/src/common/backend-profiles.ts b/src/common/backend-profiles.ts new file mode 100644 index 0000000..de33d9d --- /dev/null +++ b/src/common/backend-profiles.ts @@ -0,0 +1,90 @@ +import type { BackendProfile, JsonRecord } from "./types.js"; + +export interface BackendProfileSpec { + profile: BackendProfile; + backendKind: "codex-app-server-stdio"; + protocol: "codex-app-server-jsonrpc-stdio"; + transport: "stdio"; + command: "codex app-server --listen stdio://"; + status: "registered"; + requiredSecretKeys: ["auth.json", "config.toml"]; + defaultSecretName: string; + profileIsolation: "profile-scoped-codex-home"; + description: string; +} + +export const backendProfileSpecs: readonly BackendProfileSpec[] = [ + { + profile: "codex", + backendKind: "codex-app-server-stdio", + protocol: "codex-app-server-jsonrpc-stdio", + transport: "stdio", + command: "codex app-server --listen stdio://", + status: "registered", + requiredSecretKeys: ["auth.json", "config.toml"], + defaultSecretName: "agentrun-v01-provider-codex", + profileIsolation: "profile-scoped-codex-home", + description: "Default Codex-compatible profile", + }, + { + profile: "deepseek", + backendKind: "codex-app-server-stdio", + protocol: "codex-app-server-jsonrpc-stdio", + transport: "stdio", + command: "codex app-server --listen stdio://", + status: "registered", + requiredSecretKeys: ["auth.json", "config.toml"], + defaultSecretName: "agentrun-v01-provider-deepseek", + profileIsolation: "profile-scoped-codex-home", + description: "DeepSeek-compatible profile through Codex app-server stdio", + }, +]; + +export const backendProfiles = backendProfileSpecs.map((item) => item.profile) as readonly BackendProfile[]; + +export function backendProfileSpec(profile: string): BackendProfileSpec | null { + return backendProfileSpecs.find((item) => item.profile === profile) ?? null; +} + +export function isBackendProfile(value: string): value is BackendProfile { + return backendProfileSpec(value) !== null; +} + +export function backendCapability(spec: BackendProfileSpec): JsonRecord { + return { + profile: spec.profile, + backendKind: spec.backendKind, + protocol: spec.protocol, + transport: spec.transport, + command: spec.command, + status: spec.status, + requiredSecretKeys: [...spec.requiredSecretKeys], + defaultSecretRef: { name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys] }, + profileIsolation: spec.profileIsolation, + description: spec.description, + }; +} + +export function backendCapabilities(): JsonRecord[] { + return backendProfileSpecs.map(backendCapability); +} + +export function backendCapabilitiesSqlValues(): string { + return backendProfileSpecs.map((spec) => { + const capabilities = JSON.stringify({ + backendKind: spec.backendKind, + protocol: spec.protocol, + transport: spec.transport, + command: spec.command, + requiredSecretKeys: spec.requiredSecretKeys, + defaultSecretRef: { name: spec.defaultSecretName, keys: spec.requiredSecretKeys }, + profileIsolation: spec.profileIsolation, + description: spec.description, + }); + return `('${sqlString(spec.profile)}', '${sqlString(capabilities)}'::jsonb, '{"mode":"manual-runner-v0.1"}'::jsonb, '{"status":"${sqlString(spec.status)}"}'::jsonb, now())`; + }).join(",\n"); +} + +function sqlString(value: string): string { + return value.replace(/'/gu, "''"); +} diff --git a/src/common/types.ts b/src/common/types.ts index a516722..edfd58f 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -22,7 +22,7 @@ export type FailureKind = export type RunStatus = "pending" | "claimed" | "running" | "completed" | "failed" | "blocked" | "cancelled"; export type CommandState = "pending" | "acknowledged" | "completed" | "failed" | "cancelled"; export type TerminalStatus = "completed" | "failed" | "blocked" | "cancelled"; -export type BackendProfile = "codex"; +export type BackendProfile = "codex" | "deepseek"; export interface WorkspaceRef extends JsonRecord { kind: "git-worktree" | "host-path" | "kubernetes-pvc" | "opaque"; diff --git a/src/common/validation.ts b/src/common/validation.ts index 495ee5d..04b002f 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -1,9 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; import type { BackendProfile, CreateCommandInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue } from "./types.js"; import { AgentRunError } from "./errors.js"; +import { backendProfileSpec, backendProfiles, isBackendProfile } from "./backend-profiles.js"; const allowedTenants = new Set(["unidesk", "hwlab"]); -const allowedBackends = new Set(["codex"]); export function nowIso(): string { return new Date().toISOString(); @@ -42,9 +42,11 @@ export function validateCreateRun(input: unknown): CreateRunInput { const record = asRecord(input, "run"); const tenantId = requiredString(record, "tenantId"); if (!allowedTenants.has(tenantId)) throw new AgentRunError("tenant-policy-denied", `tenantId ${tenantId} is not allowed`, { httpStatus: 403 }); - const backendProfile = requiredString(record, "backendProfile") as BackendProfile; - if (!allowedBackends.has(backendProfile)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfile} is not supported in v0.1`, { httpStatus: 400 }); + const backendProfileValue = requiredString(record, "backendProfile"); + if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} is not supported in v0.1`, { httpStatus: 400, details: { allowedBackends: [...backendProfiles] } }); + const backendProfile = backendProfileValue as BackendProfile; const executionPolicy = validateExecutionPolicy(requiredRecord(record, "executionPolicy")); + validateBackendSecretScope(backendProfile, executionPolicy); return { tenantId, projectId: requiredString(record, "projectId"), @@ -64,8 +66,15 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { const providerCredentials = Array.isArray(secretScope.providerCredentials) ? secretScope.providerCredentials : []; for (const credential of providerCredentials) { const item = asRecord(credential, "providerCredential"); + const profile = typeof item.profile === "string" ? item.profile.trim() : ""; + if (profile.length === 0) throw new AgentRunError("schema-invalid", "provider credential profile is required", { httpStatus: 400 }); + if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `provider credential profile ${profile} is not supported in v0.1`, { httpStatus: 400, details: { allowedBackends: [...backendProfiles] } }); const secretRef = asRecord(item.secretRef, "providerCredential.secretRef"); if (typeof secretRef.name !== "string" || secretRef.name.length === 0) throw new AgentRunError("schema-invalid", "provider credential secretRef.name is required", { httpStatus: 400 }); + const keys = Array.isArray(secretRef.keys) ? secretRef.keys : []; + for (const requiredKey of backendProfileSpec(profile)?.requiredSecretKeys ?? []) { + if (!keys.includes(requiredKey)) throw new AgentRunError("schema-invalid", `provider credential ${profile} secretRef.keys must include ${requiredKey}`, { httpStatus: 400 }); + } } const secretScopeResult: ExecutionPolicy["secretScope"] = { allowCredentialEcho: false }; if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable; @@ -78,6 +87,15 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { }; } +function validateBackendSecretScope(backendProfile: BackendProfile, executionPolicy: ExecutionPolicy): void { + const credentials = executionPolicy.secretScope.providerCredentials ?? []; + const matching = credentials.filter((item) => item.profile === backendProfile); + if (matching.length === 0) { + throw new AgentRunError("secret-unavailable", `backendProfile ${backendProfile} requires a matching provider credential SecretRef`, { httpStatus: 400, details: { backendProfile, requiredSecretName: backendProfileSpec(backendProfile)?.defaultSecretName ?? null } }); + } + if (matching.length > 1) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfile} has multiple matching provider credentials`, { httpStatus: 400 }); +} + export function validateCreateCommand(input: unknown): CreateCommandInput { const record = asRecord(input, "command"); const type = requiredString(record, "type"); diff --git a/src/mgr/postgres-store.ts b/src/mgr/postgres-store.ts index 1d88aef..2c59cf0 100644 --- a/src/mgr/postgres-store.ts +++ b/src/mgr/postgres-store.ts @@ -7,6 +7,7 @@ import type { BackendProfile, BackendTurnResult, CommandRecord, CommandState, Cr import { newId, nowIso, stableHash } from "../common/validation.js"; import type { AgentRunStore, StoreHealth } from "./store.js"; import { commandStateFromTerminal, statusFromTerminal } from "./store.js"; +import { backendCapabilitiesSqlValues } from "../common/backend-profiles.js"; interface PostgresStoreOptions { connectionString: string; @@ -115,12 +116,27 @@ ON CONFLICT (profile) DO UPDATE SET updated_at = EXCLUDED.updated_at; `; +const backendProfilesMigrationSql = ` +INSERT INTO agentrun_backends (profile, capabilities, capacity, health, updated_at) +VALUES ${backendCapabilitiesSqlValues()} +ON CONFLICT (profile) DO UPDATE SET + capabilities = EXCLUDED.capabilities, + capacity = EXCLUDED.capacity, + health = EXCLUDED.health, + updated_at = EXCLUDED.updated_at; +`; + const postgresMigrations: MigrationDefinition[] = [ { id: "001_v01_initial_durable_store", checksum: checksumSql(initialMigrationSql), sql: initialMigrationSql, }, + { + id: "002_v01_backend_profiles", + checksum: checksumSql(backendProfilesMigrationSql), + sql: backendProfilesMigrationSql, + }, ]; export function postgresMigrationContract(): JsonRecord { diff --git a/src/mgr/store.ts b/src/mgr/store.ts index 1f4fa75..00d76ab 100644 --- a/src/mgr/store.ts +++ b/src/mgr/store.ts @@ -2,6 +2,7 @@ import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInp import { AgentRunError } from "../common/errors.js"; import { newId, nowIso, stableHash } from "../common/validation.js"; import { redactJson } from "../common/redaction.js"; +import { backendCapabilities } from "../common/backend-profiles.js"; export type MaybePromise = T | Promise; @@ -158,7 +159,7 @@ export class MemoryAgentRunStore implements AgentRunStore { } backends(): JsonRecord[] { - return [{ profile: "codex" satisfies BackendProfile, protocol: "codex-app-server-jsonrpc-stdio", transport: "stdio", command: "codex app-server --listen stdio://", status: "registered" }]; + return backendCapabilities(); } private updateRun(runId: string, patch: Partial): RunRecord { diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index d3eeec9..3b034f3 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -1,5 +1,6 @@ import { stableHash } from "../common/validation.js"; import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue, RunRecord, SecretRef } from "../common/types.js"; +import { backendProfileSpec } from "../common/backend-profiles.js"; export interface RunnerJobRenderOptions { run: RunRecord; @@ -130,8 +131,8 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani } function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[] }): JsonRecord[] { - const codexSecret = context.secretRefs.find((item) => item.profile === "codex"); - const codexHome = codexSecret?.runtimeMountPath ?? "/home/agentrun/.codex"; + const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile); + const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile); return [ { name: "AGENTRUN_MGR_URL", value: options.managerUrl }, { name: "AGENTRUN_RUN_ID", value: options.run.id }, @@ -146,18 +147,18 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string { name: "AGENTRUN_LOG_PATH", value: "/tmp/agentrun-runner.jsonl" }, { name: "HOME", value: "/home/agentrun" }, { name: "CODEX_HOME", value: codexHome }, - ...(codexSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: codexSecret.projectionMountPath }] : []), + ...(selectedSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: selectedSecret.projectionMountPath }] : []), ]; } function credentialProjections(run: RunRecord, namespace: string): CredentialProjection[] { const policy: ExecutionPolicy = run.executionPolicy; - const credentials = policy.secretScope.providerCredentials ?? []; + const credentials = (policy.secretScope.providerCredentials ?? []).filter((item) => item.profile === run.backendProfile); return credentials.map((item, index) => ({ profile: item.profile, secretRef: item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace }, volumeName: sanitizeVolumeName(`${String(item.profile)}-${index}`), - runtimeMountPath: normalizeMountPath(item.secretRef.mountPath), + runtimeMountPath: normalizeMountPath(item.secretRef.mountPath, String(item.profile)), projectionMountPath: `/var/run/agentrun/secrets/${sanitizeVolumeName(`${String(item.profile)}-${index}`)}`, })); } @@ -172,12 +173,18 @@ function secretVolume(item: CredentialProjection): JsonRecord { return { name: item.volumeName, secret }; } -function normalizeMountPath(value: string | undefined): string { - if (!value || value === "~/.codex") return "/home/agentrun/.codex"; - if (value.startsWith("~/")) return `/home/agentrun/${value.slice(2)}`; +function normalizeMountPath(value: string | undefined, profile: string): string { + const spec = backendProfileSpec(profile); + const suffix = spec ? spec.profile : sanitizeVolumeName(profile); + if (!value || value === "~/.codex") return defaultRuntimeHome(suffix); + if (value.startsWith("~/")) return `/home/agentrun/${value.slice(2)}-${suffix}`; return value; } +function defaultRuntimeHome(profile: string): string { + return `/home/agentrun/.codex-${sanitizeVolumeName(profile)}`; +} + function labels(run: RunRecord, jobName: string): JsonRecord { return { "app.kubernetes.io/name": "agentrun-runner", diff --git a/src/runner/main.ts b/src/runner/main.ts index 722e2d4..e5350ef 100644 --- a/src/runner/main.ts +++ b/src/runner/main.ts @@ -1,6 +1,7 @@ import { runOnce, type RunnerOnceOptions } from "./run-once.js"; import { AgentRunError, errorToJson } from "../common/errors.js"; import { failureKindFromError } from "./manager-api.js"; +import { isBackendProfile } from "../common/backend-profiles.js"; const managerUrl = process.env.AGENTRUN_MGR_URL; const runId = process.env.AGENTRUN_RUN_ID; @@ -16,7 +17,13 @@ const options: RunnerOnceOptions = { if (process.env.AGENTRUN_COMMAND_ID) options.commandId = process.env.AGENTRUN_COMMAND_ID; if (process.env.AGENTRUN_ATTEMPT_ID) options.attemptId = process.env.AGENTRUN_ATTEMPT_ID; if (process.env.AGENTRUN_RUNNER_ID) options.runnerId = process.env.AGENTRUN_RUNNER_ID; -if (process.env.AGENTRUN_BACKEND_PROFILE === "codex") options.backendProfile = "codex"; +if (process.env.AGENTRUN_BACKEND_PROFILE) { + if (!isBackendProfile(process.env.AGENTRUN_BACKEND_PROFILE)) { + console.log(JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `AGENTRUN_BACKEND_PROFILE ${process.env.AGENTRUN_BACKEND_PROFILE} is not supported in v0.1` })); + process.exit(2); + } + options.backendProfile = process.env.AGENTRUN_BACKEND_PROFILE; +} if (process.env.AGENTRUN_K8S_JOB_NAME) options.placement = "kubernetes-job"; if (process.env.AGENTRUN_SOURCE_COMMIT) options.sourceCommit = process.env.AGENTRUN_SOURCE_COMMIT; if (process.env.AGENTRUN_K8S_JOB_NAME) options.jobName = process.env.AGENTRUN_K8S_JOB_NAME; diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index 21b9da8..45d874a 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -1,6 +1,7 @@ import { RunnerManagerApi, failureKindFromError, terminalStatusForFailure, errorMessage } from "./manager-api.js"; import { runBackendTurn, type BackendAdapterOptions } from "../backend/adapter.js"; import type { BackendProfile, JsonRecord, RunRecord, RunnerRecord } from "../common/types.js"; +import { AgentRunError } from "../common/errors.js"; export interface RunnerOnceOptions extends BackendAdapterOptions { managerUrl: string; @@ -19,12 +20,16 @@ export interface RunnerOnceOptions extends BackendAdapterOptions { export async function runOnce(options: RunnerOnceOptions): Promise { const api = new RunnerManagerApi(options.managerUrl); + const targetRun = await api.client.get(`/api/v1/runs/${encodeURIComponent(options.runId)}`) as RunRecord; + if (options.backendProfile && options.backendProfile !== targetRun.backendProfile) { + throw new AgentRunError("schema-invalid", `runner backendProfile ${options.backendProfile} does not match run backendProfile ${targetRun.backendProfile}`, { httpStatus: 400 }); + } const leaseMs = options.leaseMs ?? 60_000; const attemptId = options.attemptId ?? `attempt_${Date.now().toString(36)}`; const runner = await api.register({ runId: options.runId, attemptId, - backendProfile: options.backendProfile ?? "codex", + backendProfile: targetRun.backendProfile, placement: options.placement ?? "host-process", sourceCommit: options.sourceCommit ?? process.env.AGENTRUN_SOURCE_COMMIT ?? "unknown", ...(options.runnerId ? { runnerId: options.runnerId } : {}), diff --git a/src/selftest/cases/00-redaction-postgres.ts b/src/selftest/cases/00-redaction-postgres.ts index c87bc62..5225a69 100644 --- a/src/selftest/cases/00-redaction-postgres.ts +++ b/src/selftest/cases/00-redaction-postgres.ts @@ -13,7 +13,7 @@ const selfTest: SelfTestCase = async () => { (error) => error instanceof AgentRunError && error.failureKind === "infra-failed" && error.message.includes("DATABASE_URL is required"), ); const postgresContract = postgresMigrationContract(); - assert.equal(postgresContract.latestMigrationId, "001_v01_initial_durable_store"); + assert.equal(postgresContract.latestMigrationId, "002_v01_backend_profiles"); assert.ok(Array.isArray(postgresContract.requiredTables)); assert.ok(postgresContract.requiredTables.includes("agentrun_schema_migrations")); assert.ok(postgresContract.requiredTables.includes("agentrun_runs")); diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 7c6a4f9..1b8c026 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -25,9 +25,22 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(rendered.mutation, false); 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); + assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0"); assertNoSecretLeak(rendered); + 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, + commandId: deepseekItem.commandId, + managerUrl: server.baseUrl, + image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111", + attemptId: "attempt_selftest_deepseek", + sourceCommit: "self-test", + }); + assertRunnerJobUsesWritableCodexHome(deepseekRendered.manifest as JsonRecord, context.deepseekHome, "deepseek-0", "/var/run/agentrun/secrets/deepseek-0"); + assertRunnerJobDoesNotMountProfile(deepseekRendered.manifest as JsonRecord, "codex-0"); + assertNoSecretLeak(deepseekRendered); + const fakeKubectl = path.join(context.tmp, "fake-kubectl.js"); const createdManifest = path.join(context.tmp, "created-runner-job.json"); await writeFile(fakeKubectl, `#!/usr/bin/env bun @@ -64,7 +77,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin } finally { await new Promise((resolve) => serverWithKubectl.server.close(() => resolve())); } - return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl"] }; + 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"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } @@ -72,7 +85,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin export default selfTest; -function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string): void { +function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void { const spec = manifest.spec as JsonRecord; const template = spec.template as JsonRecord; const podSpec = template.spec as JsonRecord; @@ -83,12 +96,24 @@ function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCode const runner = containers[0] as JsonRecord; const mounts = runner.volumeMounts as JsonRecord[]; assert.ok(mounts.some((mount) => mount.name === "runner-home" && mount.mountPath === "/home/agentrun"), "runner-home must mount at /home/agentrun"); - assert.ok(mounts.some((mount) => mount.name === "codex-0" && mount.mountPath === "/var/run/agentrun/secrets/codex-0" && mount.readOnly === true), "Codex Secret must mount read-only outside CODEX_HOME"); + assert.ok(mounts.some((mount) => mount.name === volumeName && mount.mountPath === projectionPath && mount.readOnly === true), "Codex Secret must mount read-only outside CODEX_HOME"); const env = runner.env as JsonRecord[]; const value = (name: string): unknown => env.find((item) => item.name === name)?.value; assert.equal(value("HOME"), "/home/agentrun"); assert.equal(value("CODEX_HOME"), expectedCodexHome); - assert.equal(value("AGENTRUN_CODEX_SECRET_HOME"), "/var/run/agentrun/secrets/codex-0"); + assert.equal(value("AGENTRUN_CODEX_SECRET_HOME"), projectionPath); assert.notEqual(value("CODEX_HOME"), value("AGENTRUN_CODEX_SECRET_HOME")); } + +function assertRunnerJobDoesNotMountProfile(manifest: JsonRecord, volumeName: string): void { + const spec = manifest.spec as JsonRecord; + const template = spec.template as JsonRecord; + const podSpec = template.spec as JsonRecord; + const volumes = podSpec.volumes as JsonRecord[]; + const containers = podSpec.containers as JsonRecord[]; + const runner = containers[0] as JsonRecord; + const mounts = runner.volumeMounts as JsonRecord[]; + assert.equal(volumes.some((volume) => volume.name === volumeName), false, `${volumeName} volume must not be mounted for another backendProfile`); + assert.equal(mounts.some((mount) => mount.name === volumeName), false, `${volumeName} mount must not exist for another backendProfile`); +} diff --git a/src/selftest/cases/30-codex-stdio.ts b/src/selftest/cases/30-codex-stdio.ts index d759d6b..0dbbf2d 100644 --- a/src/selftest/cases/30-codex-stdio.ts +++ b/src/selftest/cases/30-codex-stdio.ts @@ -33,6 +33,21 @@ const selfTest: SelfTestCase = async (context) => { await access(path.join(projectedHome, "auth.json")); await access(path.join(projectedHome, "config.toml")); + const deepseekHome = path.join(context.tmp, "runtime-deepseek-home"); + const deepseek = await createRunWithCommand(client, { ...context, backendProfile: "deepseek" }, "hello deepseek", "selftest-deepseek-turn", 15_000); + const deepseekResult = await runOnce({ managerUrl: server.baseUrl, runId: deepseek.runId, backendProfile: "deepseek", codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: deepseekHome, env: { CODEX_HOME: deepseekHome, AGENTRUN_CODEX_SECRET_HOME: context.deepseekHome } }); + assert.equal(deepseekResult.terminalStatus, "completed"); + await access(path.join(deepseekHome, "auth.json")); + await access(path.join(deepseekHome, "config.toml")); + const deepseekEvents = await client.get(`/api/v1/runs/${deepseek.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: unknown }> }; + assert.ok(deepseekEvents.items?.some((event) => event.type === "backend_status" && JSON.stringify(event.payload).includes("deepseek")), "deepseek backend_status should include profile metadata"); + assertNoSecretLeak(deepseekEvents); + + await assert.rejects( + () => createRunWithCommand(client, { ...context, backendProfile: "deepseek", includeOnlyProfile: "codex" }, "missing deepseek", "selftest-deepseek-missing-secret", 15_000), + (error) => error instanceof Error && error.message.includes("requires a matching provider credential"), + ); + const configModel = await createRunWithCommand(client, context, "hello config model", "selftest-config-model", 15_000); const configModelResult = await runOnce({ managerUrl: server.baseUrl, runId: configModel.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "reject-unexpected-model" } }); assert.equal(configModelResult.terminalStatus, "completed", "unspecified model should be omitted so Codex config.toml remains authoritative"); @@ -50,7 +65,7 @@ const selfTest: SelfTestCase = async (context) => { await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "missing-terminal", expectedStatus: "failed", expectedFailureKind: "backend-timeout", timeoutMs: 500 }); await runSpawnFailureCase({ client, managerUrl: server.baseUrl, context }); - return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-missing-turn-result", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-spawn-failure"] }; + return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-missing-turn-result", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-spawn-failure"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/40-secret-render.ts b/src/selftest/cases/40-secret-render.ts index dee1f75..5e1e2e8 100644 --- a/src/selftest/cases/40-secret-render.ts +++ b/src/selftest/cases/40-secret-render.ts @@ -9,6 +9,7 @@ const selfTest: SelfTestCase = async (context) => { const secretPlan = await renderCodexProviderSecretPlan({ codexHome: context.codexHome, dryRun: true }); assert.equal(secretPlan.namespace, "agentrun-v01"); assert.equal(secretPlan.secretName, "agentrun-v01-provider-codex"); + assert.equal(secretPlan.profile, "codex"); assert.deepEqual(secretPlan.keys, ["auth.json", "config.toml"]); assert.equal(secretPlan.writeAttempted, false); assert.equal(secretPlan.totalBytes, Buffer.byteLength(JSON.stringify({ token: "test-token-material" }), "utf8") + Buffer.byteLength("model = \"gpt-test\"\n", "utf8")); @@ -18,6 +19,12 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(renderedSecretJson.includes("gpt-test"), false); assert.equal(renderedSecretJson.includes("model ="), false); + const deepseekSecretPlan = await renderCodexProviderSecretPlan({ profile: "deepseek", codexHome: context.deepseekHome, dryRun: true }); + assert.equal(deepseekSecretPlan.secretName, "agentrun-v01-provider-deepseek"); + assert.equal(deepseekSecretPlan.profile, "deepseek"); + assert.equal(JSON.stringify(deepseekSecretPlan).includes("test-token-material-deepseek"), false); + assert.equal(JSON.stringify(deepseekSecretPlan).includes("deepseek-test"), false); + await assert.rejects( () => renderCodexProviderSecretPlan({ codexHome: path.join(context.tmp, "missing-codex-home"), dryRun: true }), (error) => error instanceof AgentRunError && error.failureKind === "secret-unavailable", @@ -42,7 +49,7 @@ const selfTest: SelfTestCase = async (context) => { (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid", ); - return { name: "secret-render", tests: ["codex-secret-dry-run"] }; + return { name: "secret-render", tests: ["codex-secret-dry-run", "deepseek-secret-dry-run"] }; }; export default selfTest; diff --git a/src/selftest/harness.ts b/src/selftest/harness.ts index 40ed9a0..fbf4d2b 100644 --- a/src/selftest/harness.ts +++ b/src/selftest/harness.ts @@ -3,12 +3,14 @@ import os from "node:os"; import path from "node:path"; import assert from "node:assert/strict"; import { ManagerClient } from "../mgr/client.js"; -import type { JsonRecord } from "../common/types.js"; +import type { BackendProfile, JsonRecord } from "../common/types.js"; +import { backendProfileSpec } from "../common/backend-profiles.js"; export interface SelfTestContext { root: string; tmp: string; codexHome: string; + deepseekHome: string; workspace: string; fakeCodexPath: string; fakeCodexCommand: string; @@ -26,17 +28,22 @@ export type SelfTestCase = (context: SelfTestContext) => Promise export async function createSelfTestContext(root: string): Promise { const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-")); const codexHome = path.join(tmp, "codex-home"); + const deepseekHome = path.join(tmp, "deepseek-home"); const workspace = path.join(tmp, "workspace"); await mkdir(codexHome, { recursive: true }); + await mkdir(deepseekHome, { recursive: true }); await mkdir(workspace, { recursive: true }); await writeFile(path.join(codexHome, "auth.json"), JSON.stringify({ token: "test-token-material" })); await writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-test\"\n"); + await writeFile(path.join(deepseekHome, "auth.json"), JSON.stringify({ token: "test-token-material-deepseek" })); + await writeFile(path.join(deepseekHome, "config.toml"), "model = \"deepseek-test\"\n"); await writeFile(path.join(workspace, "README.md"), "self-test workspace\n"); const fakeCodexPath = path.join(root, "src/selftest/fake-codex-app-server.ts"); return { root, tmp, codexHome, + deepseekHome, workspace, fakeCodexPath, fakeCodexCommand: process.env.AGENTRUN_SELFTEST_CODEX_COMMAND ?? defaultFakeCommand(), @@ -45,19 +52,20 @@ export async function createSelfTestContext(root: string): Promise, prompt: string, idempotencyKey: string, timeoutMs: number): Promise<{ runId: string; commandId: string }> { +export async function createRunWithCommand(client: ManagerClient, context: Pick & Partial> & { backendProfile?: BackendProfile; includeOnlyProfile?: BackendProfile }, 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", projectId: "pikasTech/unidesk", workspaceRef: { kind: "host-path", path: context.workspace }, providerId: "G14", - backendProfile: "codex", + backendProfile, executionPolicy: { sandbox: "workspace-write", approval: "never", timeoutMs, network: "default", - secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile: "codex", secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"], mountPath: context.codexHome } }] }, + secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile) }, }, traceSink: null, }) as { id: string }; @@ -67,9 +75,22 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick< return { runId: run.id, commandId: command.id }; } +function providerCredentials(context: Pick & Partial> & { includeOnlyProfile?: BackendProfile }, backendProfile: BackendProfile): JsonRecord[] { + const profiles: BackendProfile[] = context.includeOnlyProfile ? [context.includeOnlyProfile] : [backendProfile]; + return profiles.map((profile) => ({ + profile, + secretRef: { + name: backendProfileSpec(profile)?.defaultSecretName ?? `agentrun-v01-provider-${profile}`, + keys: ["auth.json", "config.toml"], + mountPath: profile === "deepseek" ? context.deepseekHome ?? context.codexHome : context.codexHome, + }, + })); +} + export function assertNoSecretLeak(value: unknown): void { const text = JSON.stringify(value); assert.equal(text.includes("test-token-material"), false); + assert.equal(text.includes("test-token-material-deepseek"), false); assert.equal(text.includes("Bearer test-token"), false); }