feat: 支持 v0.1 deepseek backend profile
This commit is contained in:
+11
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 和部署级观测仍属后续工作。 |
|
||||
|
||||
@@ -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 联调需发布后执行。 |
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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 注入。 |
|
||||
|
||||
@@ -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 联调需发布后执行。 |
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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,后续单独更新规格。 |
|
||||
|
||||
@@ -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` 第一阶段验收目标。 |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user