feat: 支持 v0.1 deepseek backend profile

This commit is contained in:
Codex
2026-05-29 18:44:24 +08:00
parent 5375ce37f7
commit 5cc8146800
28 changed files with 303 additions and 50 deletions
+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);
}