Merge pull request #25 from pikasTech/feat/v01-deepseek-profile

feat: 支持 v0.1 deepseek backend profile
This commit is contained in:
Lyon
2026-05-29 18:45:57 +08:00
committed by GitHub
28 changed files with 303 additions and 50 deletions
+11 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 storememory 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 和部署级观测仍属后续工作。 |
+1 -1
View File
@@ -112,4 +112,4 @@ Runner 日志必须实时 flush 到文件或 pod logCLI 启动 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 statuslive 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 联调需发布后执行。 |
+3 -3
View File
@@ -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 的 SecretRefrunner 只为当前 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。 |
+2 -2
View File
@@ -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-<profile>`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 注入。 |
+1 -1
View File
@@ -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 联调需发布后执行。 |
+1 -1
View File
@@ -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/checksumreadiness 必须显示 migration ready。 |
| migration ledger | 已实现/已通过主闭环 | `agentrun-mgr` 启动 Postgres adapter 时幂等创建 `agentrun_schema_migrations` 并记录 migration id/checksum当前最新 migration 为 `002_v01_backend_profiles`,用于 upsert `codex`/`deepseek` backend capabilityreadiness 必须显示 migration ready。 |
| manager Postgres adapter | 已实现/已通过主闭环 | `agentrun-mgr` 通过 `DATABASE_URL` 启用 Postgres adapter,持久化 runs、commands、events、runners、backends 和 leases;缺少 `DATABASE_URL` 时 live runtime fail fastmemory 只允许显式 self-test/dev。 |
| health/readiness store 状态 | 已实现 | health/readiness 返回 adapter、reachable、migrationReady、migrationId、failureKind 和 redacted Secret 状态,不输出 DSN 明文。 |
| file/sqlite durable store | 不采用 | 只可用于临时本地测试,不作为 v0.1 runtime truth。 |
@@ -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/<profile>-<index>/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-<profile>/auth.json``config.toml`;复用进程必须使用 run/profile 独占目录。 |
| Projection mode | 只读,建议 `0400` 或等价最小权限 |
| Runtime env | `HOME=/home/agentrun``CODEX_HOME=/home/agentrun/.codex``AGENTRUN_CODEX_SECRET_HOME=<projection path>`;不得 fallback 到节点宿主机 home。 |
| Runtime env | `HOME=/home/agentrun``CODEX_HOME=/home/agentrun/.codex-<profile>``AGENTRUN_CODEX_SECRET_HOME=<projection path>`;不得 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,后续单独更新规格。 |
+1 -1
View File
@@ -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` 第一阶段验收目标。 |
+1 -1
View File
@@ -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 只能证明自测试通过。 |
+10 -2
View File
@@ -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<JsonValue> {
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<JsonRecord> {
throw new AgentRunError("schema-invalid", "secrets codex render requires --dry-run", { httpStatus: 2 });
}
const options: Parameters<typeof renderCodexProviderSecretPlan>[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 <runId> --after-seq <n> --limit <n>",
"commands create <runId> --type turn --json-file <payload.json>",
"commands show <commandId> --run-id <runId>",
"runner start --run-id <runId>",
"runner start --run-id <runId> [--backend codex|deepseek]",
"runner job --run-id <runId> --command-id <commandId> [--image <image>] [--runner-manager-url <url>]",
"runner job --dry-run --run-id <runId> --command-id <commandId> --image <image>",
"secrets codex render --dry-run [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name agentrun-v01-provider-codex]",
"secrets codex render --dry-run [--profile codex|deepseek] [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name <name>]",
"backends list",
"server start|status",
],
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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"),
+4 -1
View File
@@ -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<BackendTurnResult> {
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,
+16 -2
View File
@@ -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<string, JsonValue> = {};
for (const key of childEnvSummaryKeys) keyState[key] = { present: typeof env[key] === "string" && String(env[key]).length > 0 };
+90
View File
@@ -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, "''");
}
+1 -1
View File
@@ -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";
+21 -3
View File
@@ -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<BackendProfile>(["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<ExecutionPolicy["secretScope"]["providerCredentials"]>;
@@ -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");
+16
View File
@@ -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 {
+2 -1
View File
@@ -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> = T | Promise<T>;
@@ -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>): RunRecord {
+15 -8
View File
@@ -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",
+8 -1
View File
@@ -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;
+6 -1
View File
@@ -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<JsonRecord> {
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 } : {}),
+1 -1
View File
@@ -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"));
+30 -5
View File
@@ -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<void>((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<void>((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`);
}
+16 -1
View File
@@ -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<void>((resolve) => server.server.close(() => resolve()));
}
+8 -1
View File
@@ -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;
+25 -4
View File
@@ -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<SelfTestResult>
export async function createSelfTestContext(root: string): Promise<SelfTestContext> {
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<SelfTestConte
};
}
export async function createRunWithCommand(client: ManagerClient, context: Pick<SelfTestContext, "workspace" | "codexHome">, prompt: string, idempotencyKey: string, timeoutMs: number): Promise<{ runId: string; commandId: string }> {
export async function createRunWithCommand(client: ManagerClient, context: Pick<SelfTestContext, "workspace" | "codexHome"> & Partial<Pick<SelfTestContext, "deepseekHome">> & { 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<SelfTestContext, "codexHome"> & Partial<Pick<SelfTestContext, "deepseekHome">> & { 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);
}