feat: 支持 runner tool credential 装配
This commit is contained in:
@@ -9,7 +9,7 @@ ENV PORT=8080
|
||||
ENV AGENTRUN_CODEX_COMMAND=/app/node_modules/.bin/codex
|
||||
|
||||
RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \
|
||||
apk add --no-cache ca-certificates git kubectl nodejs openssh-client
|
||||
apk add --no-cache ca-certificates curl git github-cli kubectl nodejs openssh-client
|
||||
|
||||
COPY package.json tsconfig.json ./
|
||||
RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \
|
||||
|
||||
@@ -187,4 +187,4 @@ HWLAB v0.2 原有 Code Agent 已经验证了 profile、session、workspace 和 S
|
||||
| `ProfileRef` | 已实现/已通过主闭环 | `codex` 与 `deepseek` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证。 |
|
||||
| `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 |
|
||||
| `ResourceBundleRef` | 已实现 Git-only materialization | `repoUrl + full commitId` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 commit/tree/workspace 摘要。 |
|
||||
| `toolCredentials` | 已定义/待实现 | GitHub PR 等 agent shell/tool 授权必须通过装配 SPEC 的 SecretRef 进入 runner;当前发现缺 `gh`/token 时按装配缺口处理,不用 `transientEnv` 绕过。 |
|
||||
| `toolCredentials` | 已实现最小 env projection | GitHub PR 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 先支持 `tool=github`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 |
|
||||
|
||||
@@ -187,5 +187,6 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声
|
||||
| Codex Secret dry-run 工具 | 已实现 | `./scripts/agentrun secrets codex render --dry-run` 只输出 Secret 创建计划、hash 和 redacted manifest 摘要,不执行 apply。 |
|
||||
| Codex auth/config file projection | 已实现主路径 | backend readiness 检查 `auth.json`/`config.toml` 可读性,缺失时返回 `secret-unavailable`;真实 runner Job 将只读 projection 复制到 writable `CODEX_HOME`。 |
|
||||
| DeepSeek profile SecretRef | 已实现/已通过主闭环 | 已新增 `agentrun-v01-provider-deepseek` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建与 Kubernetes Job projection 已通过主闭环,轮换仍由 Kubernetes 密钥管理流程完成。 |
|
||||
| Tool credential SecretRef | 已实现最小 env projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github` 与 `projection.kind=env`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 |
|
||||
| redaction 最小规则 | 已实现主路径 | Secret dry-run 工具、event、Job dry-run 输出、self-test 和真实主闭环均不打印 Secret value;复杂审计按 [spec-v01-validation.md](spec-v01-validation.md) 人工抽查。 |
|
||||
| 外部 secret manager | 未采用 | 如需 Vault/ExternalSecrets/SOPS,后续单独更新规格。 |
|
||||
|
||||
@@ -84,7 +84,7 @@ Runner inbound API 只允许本地或私有诊断,不作为业务客户端入
|
||||
| AgentRun CLI | CLI/Job 工具 | 保留,P0 | JSON 输出、短返回、run/command/event/runner/backend 操作入口。 | `spec-v01-cli.md` |
|
||||
| Postgres durable store | 稳定外部服务 | 保留,P0 | 使用 `agentrun-v01-postgres` 保存 runs、commands、events、runners、backends、leases 和 migration ledger;不使用 file/sqlite 作为 v0.1 durable store。 | `spec-v01-postgres.md` |
|
||||
| Secret distribution | 系统能力 | 保留,P0 | Provider credential 只通过 Kubernetes SecretRef、ServiceAccount/RBAC 和 runner env/file projection 分发;Codex 测试凭据使用 `~/.codex/auth.json` 与 `~/.codex/config.toml` 生成 Secret projection;source、GitOps、logs 和 events 不保存明文。 | `spec-v01-secret-distribution.md` |
|
||||
| RuntimeAssembly | 系统能力 | 保留,P0 规格 | runner/backend 启动前的装配 SPEC:`BackendImageRef`、`ProfileRef`、`SessionRef`、Git-only `ResourceBundleRef` 和 tool credential SecretRef scope;ProfileRef、SessionRef 和 ResourceBundleRef 已具备 v0.1 最小实现,tool credential 待实现。 | `spec-v01-runtime-assembly.md` |
|
||||
| RuntimeAssembly | 系统能力 | 保留,P0 规格 | runner/backend 启动前的装配 SPEC:`BackendImageRef`、`ProfileRef`、`SessionRef`、Git-only `ResourceBundleRef` 和 tool credential SecretRef scope;ProfileRef、SessionRef、ResourceBundleRef 和 GitHub tool credential env projection 已具备 v0.1 最小实现。 | `spec-v01-runtime-assembly.md` |
|
||||
| HWLAB 手动调度接入 | canary 集成目标 | 保留,P0 规格 | HWLAB `hwlab-cloud-api` 显式创建 run/command 并调用 runner Job API;AgentRun 提供 durable facts、events、cancel、bundle 和 session 能力。 | `spec-v01-hwlab-manual-dispatch.md` |
|
||||
| Tenant policy boundary | Run schema 合同 | 保留,P0 | 作为 `Run` 的必填字段和最小校验存在,不做独立 policy engine;tenant 的业务授权仍由 UniDesk/HWLAB 判定。 | 并入 `spec-v01-agentrun-mgr.md` |
|
||||
| Observability | 最小事件/日志合同 | 保留,P1 子项 | 作为 manager/runner 的 event、terminal status、failureKind、logPath 和 redaction 最小合同,不拆独立观测系统。 | 并入 `spec-v01-agentrun-mgr.md`、`spec-v01-agentrun-runner.md` |
|
||||
|
||||
@@ -68,6 +68,16 @@ export interface ExecutionPolicy extends JsonRecord {
|
||||
profile: BackendProfile | string;
|
||||
secretRef: SecretRef;
|
||||
}>;
|
||||
toolCredentials?: Array<{
|
||||
tool: string;
|
||||
purpose?: string;
|
||||
secretRef: SecretRef;
|
||||
projection: {
|
||||
kind: "env";
|
||||
envName: string;
|
||||
secretKey?: string;
|
||||
};
|
||||
}>;
|
||||
allowCredentialEcho?: false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,8 +122,10 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy {
|
||||
if (!keys.includes(requiredKey)) throw new AgentRunError("schema-invalid", `provider credential ${profile} secretRef.keys must include ${requiredKey}`, { httpStatus: 400 });
|
||||
}
|
||||
}
|
||||
const toolCredentials = validateToolCredentials(secretScope.toolCredentials);
|
||||
const secretScopeResult: ExecutionPolicy["secretScope"] = { allowCredentialEcho: false };
|
||||
if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable<ExecutionPolicy["secretScope"]["providerCredentials"]>;
|
||||
if (toolCredentials.length > 0) secretScopeResult.toolCredentials = toolCredentials;
|
||||
return {
|
||||
sandbox: requiredString(record, "sandbox"),
|
||||
approval: requiredString(record, "approval"),
|
||||
@@ -133,6 +135,37 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy {
|
||||
};
|
||||
}
|
||||
|
||||
function validateToolCredentials(value: unknown): NonNullable<ExecutionPolicy["secretScope"]["toolCredentials"]> {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "toolCredentials must be an array", { httpStatus: 400 });
|
||||
if (value.length > 8) throw new AgentRunError("schema-invalid", "toolCredentials must contain at most 8 entries", { httpStatus: 400 });
|
||||
const seen = new Set<string>();
|
||||
return value.map((credential, index) => {
|
||||
const item = asRecord(credential, `toolCredentials[${index}]`);
|
||||
const tool = requiredString(item, "tool");
|
||||
if (tool !== "github") throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: ["github"] } });
|
||||
const purpose = optionalString(item.purpose);
|
||||
const secretRef = validateSecretRef(asRecord(item.secretRef, `toolCredentials[${index}].secretRef`));
|
||||
const keys = secretRef.keys ?? [];
|
||||
if (keys.length === 0) throw new AgentRunError("schema-invalid", `tool credential ${tool} secretRef.keys must not be empty`, { httpStatus: 400 });
|
||||
const projection = asRecord(item.projection, `toolCredentials[${index}].projection`);
|
||||
const kind = requiredString(projection, "kind");
|
||||
if (kind !== "env") throw new AgentRunError("schema-invalid", "toolCredentials[].projection.kind must be env in v0.1", { httpStatus: 400 });
|
||||
const envName = requiredString(projection, "envName");
|
||||
validateEnvName(envName, `toolCredentials[${index}].projection.envName`);
|
||||
const secretKey = optionalString(projection.secretKey) ?? keys[0];
|
||||
if (!secretKey || !keys.includes(secretKey)) throw new AgentRunError("schema-invalid", `tool credential ${tool} projection.secretKey must be included in secretRef.keys`, { httpStatus: 400 });
|
||||
const identity = `${tool}:${purpose ?? ""}:${envName}`;
|
||||
if (seen.has(identity)) throw new AgentRunError("schema-invalid", `tool credential projection ${identity} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(identity);
|
||||
return { tool, ...(purpose ? { purpose } : {}), secretRef, projection: { kind: "env", envName, secretKey } };
|
||||
});
|
||||
}
|
||||
|
||||
export function validateEnvName(name: string, fieldName: string): void {
|
||||
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be an uppercase env name`, { httpStatus: 400 });
|
||||
}
|
||||
|
||||
function validateSecretRef(record: JsonRecord): SecretRef {
|
||||
const name = requiredString(record, "name");
|
||||
const result: SecretRef = { name };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redactJson, redactText } from "../common/redaction.js";
|
||||
import { isTerminalCommandState, isTerminalRunStatus, summarizeResourceBundleRef, summarizeSessionRef } from "./store.js";
|
||||
import type { AgentRunStore } from "./store.js";
|
||||
import type { JsonRecord } from "../common/types.js";
|
||||
import { stableHash } from "../common/validation.js";
|
||||
import { stableHash, validateEnvName } from "../common/validation.js";
|
||||
import { renderRunnerJobManifest } from "../runner/k8s-job.js";
|
||||
import type { RunnerTransientEnv } from "../runner/k8s-job.js";
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
logPath: `kubectl -n ${render.namespace} logs job/${render.jobName}`,
|
||||
},
|
||||
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||
transientEnv: summarizeTransientEnv(transientEnv),
|
||||
retention: {
|
||||
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
||||
@@ -148,6 +149,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
jobName: saved.jobName,
|
||||
idempotencyKey: idempotencyKey ? "present" : null,
|
||||
transientEnv: summarizeTransientEnv(transientEnv),
|
||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||
sessionRef: summarizeSessionRef(run.sessionRef ?? null),
|
||||
resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? null),
|
||||
});
|
||||
@@ -163,7 +165,8 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new AgentRunError("schema-invalid", `transientEnv[${index}] must be an object`, { httpStatus: 400 });
|
||||
const record = entry as JsonRecord;
|
||||
const name = stringField(record, "name");
|
||||
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `transientEnv[${index}].name must be an uppercase env name`, { httpStatus: 400 });
|
||||
validateEnvName(name, `transientEnv[${index}].name`);
|
||||
if (name === "GH_TOKEN" || name === "GITHUB_TOKEN" || name === "OPENAI_API_KEY" || name === "CODEX_API_KEY") throw new AgentRunError("tenant-policy-denied", `transientEnv ${name} must use tool/provider credential assembly instead`, { httpStatus: 403 });
|
||||
if (seen.has(name)) throw new AgentRunError("schema-invalid", `transientEnv name ${name} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(name);
|
||||
const rawValue = record.value;
|
||||
@@ -173,6 +176,22 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] {
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; envName: string; secretKey: string }>, namespace: string): JsonRecord {
|
||||
return {
|
||||
count: items.length,
|
||||
items: items.map((item) => ({
|
||||
tool: item.tool,
|
||||
purpose: item.purpose,
|
||||
name: item.secretRef.name,
|
||||
namespace: item.secretRef.namespace ?? namespace,
|
||||
keys: item.secretRef.keys ?? [],
|
||||
projection: { kind: "env", envName: item.envName, secretKey: item.secretKey },
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord {
|
||||
return {
|
||||
count: items.length,
|
||||
|
||||
+55
-4
@@ -33,6 +33,14 @@ interface CredentialProjection {
|
||||
projectionMountPath: string;
|
||||
}
|
||||
|
||||
interface ToolCredentialProjection {
|
||||
tool: string;
|
||||
purpose: string | null;
|
||||
secretRef: SecretRef;
|
||||
envName: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord {
|
||||
const render = renderRunnerJobManifest({ ...options, dryRun: true });
|
||||
const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []);
|
||||
@@ -56,6 +64,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
|
||||
sourceCommit: render.sourceCommit,
|
||||
},
|
||||
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||
transientEnv: summarizeTransientEnv(options.transientEnv ?? []),
|
||||
retention: {
|
||||
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
||||
@@ -69,7 +78,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
|
||||
};
|
||||
}
|
||||
|
||||
export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; warnings: string[]; ttlSecondsAfterFinished: number } {
|
||||
export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; warnings: string[]; ttlSecondsAfterFinished: number } {
|
||||
const namespace = options.namespace ?? "agentrun-v01";
|
||||
const attemptId = options.attemptId ?? `attempt_${Date.now().toString(36)}`;
|
||||
const runnerId = options.runnerId ?? `runner_${shortHash(`${options.run.id}:${attemptId}:${options.commandId}`)}`;
|
||||
@@ -78,9 +87,10 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
|
||||
const ttlSecondsAfterFinished = options.ttlSecondsAfterFinished ?? 86_400;
|
||||
const jobName = `agentrun-v01-runner-${shortDnsHash(options.run.id, attemptId)}`;
|
||||
const secretRefs = credentialProjections(options.run, namespace);
|
||||
const toolCredentials = toolCredentialProjections(options.run, namespace);
|
||||
const warnings: string[] = [];
|
||||
if (secretRefs.length === 0) warnings.push("run executionPolicy.secretScope 未声明 provider SecretRef;runner 将按 secret-unavailable 上报,而不会降级直连外部凭据");
|
||||
const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs });
|
||||
const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs, toolCredentials });
|
||||
const manifest: JsonRecord = {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
@@ -136,10 +146,10 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
|
||||
},
|
||||
},
|
||||
};
|
||||
return { manifest, namespace, jobName, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, warnings, ttlSecondsAfterFinished };
|
||||
return { manifest, namespace, jobName, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, toolCredentials, warnings, ttlSecondsAfterFinished };
|
||||
}
|
||||
|
||||
function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[] }): JsonRecord[] {
|
||||
function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[] }): JsonRecord[] {
|
||||
const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile);
|
||||
const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile);
|
||||
return [
|
||||
@@ -161,10 +171,23 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
|
||||
{ name: "HOME", value: "/home/agentrun" },
|
||||
{ name: "CODEX_HOME", value: codexHome },
|
||||
...(selectedSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: selectedSecret.projectionMountPath }] : []),
|
||||
...toolCredentialEnvVars(context.toolCredentials),
|
||||
...transientEnvVars(options.transientEnv ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] {
|
||||
return items.map((item) => ({
|
||||
name: item.envName,
|
||||
valueFrom: {
|
||||
secretKeyRef: {
|
||||
name: item.secretRef.name,
|
||||
key: item.secretKey,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function transientEnvVars(items: RunnerTransientEnv[]): JsonRecord[] {
|
||||
return items.map((item) => ({ name: item.name, value: item.value }));
|
||||
}
|
||||
@@ -177,6 +200,22 @@ function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeToolCredentials(items: ToolCredentialProjection[], namespace: string): JsonRecord {
|
||||
return {
|
||||
count: items.length,
|
||||
items: items.map((item) => ({
|
||||
tool: item.tool,
|
||||
purpose: item.purpose,
|
||||
name: item.secretRef.name,
|
||||
namespace: item.secretRef.namespace ?? namespace,
|
||||
keys: item.secretRef.keys ?? [],
|
||||
projection: { kind: "env", envName: item.envName, secretKey: item.secretKey },
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function redactTransientEnvInManifest(manifest: JsonRecord, items: RunnerTransientEnv[]): JsonRecord {
|
||||
if (items.length === 0) return manifest;
|
||||
const names = new Set(items.map((item) => item.name));
|
||||
@@ -206,6 +245,18 @@ function credentialProjections(run: RunRecord, namespace: string): CredentialPro
|
||||
}));
|
||||
}
|
||||
|
||||
function toolCredentialProjections(run: RunRecord, namespace: string): ToolCredentialProjection[] {
|
||||
const policy: ExecutionPolicy = run.executionPolicy;
|
||||
const credentials = policy.secretScope.toolCredentials ?? [];
|
||||
return credentials.map((item) => ({
|
||||
tool: item.tool,
|
||||
purpose: item.purpose ?? null,
|
||||
secretRef: item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace },
|
||||
envName: item.projection.envName,
|
||||
secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName,
|
||||
}));
|
||||
}
|
||||
|
||||
function secretVolume(item: CredentialProjection): JsonRecord {
|
||||
const secret: JsonRecord = {
|
||||
secretName: item.secretRef.name,
|
||||
|
||||
@@ -12,7 +12,13 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: new MemoryAgentRunStore() });
|
||||
try {
|
||||
const client = new ManagerClient(server.baseUrl);
|
||||
const item = await createRunWithCommand(client, context, "job smoke", "selftest-job-render", 15_000);
|
||||
const githubToolCredentials = [{
|
||||
tool: "github",
|
||||
purpose: "pull-request",
|
||||
secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] },
|
||||
projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" },
|
||||
}];
|
||||
const item = await createRunWithCommand(client, { ...context, toolCredentials: githubToolCredentials }, "job smoke", "selftest-job-render", 15_000);
|
||||
const rendered = renderRunnerJobDryRun({
|
||||
run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord,
|
||||
commandId: item.commandId,
|
||||
@@ -27,6 +33,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(((rendered.retention as JsonRecord).ttlSecondsAfterFinished), 86_400);
|
||||
assert.equal((rendered.jobIdentity as { serviceAccountName?: string }).serviceAccountName, "agentrun-v01-runner");
|
||||
assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0");
|
||||
assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "REDACTED");
|
||||
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_DEVICE_POD_SESSION_TOKEN"]);
|
||||
assertNoSecretLeak(rendered);
|
||||
@@ -70,7 +77,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
});
|
||||
try {
|
||||
const jobClient = new ManagerClient(serverWithKubectl.baseUrl);
|
||||
const jobItem = await createRunWithCommand(jobClient, context, "job create smoke", "selftest-job-create", 15_000);
|
||||
const jobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: githubToolCredentials }, "job create smoke", "selftest-job-create", 15_000);
|
||||
const created = await jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, {
|
||||
commandId: jobItem.commandId,
|
||||
attemptId: "attempt_selftest_create",
|
||||
@@ -86,11 +93,12 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.equal((manifest.spec as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
||||
assert.equal(runnerEnvValue(manifest, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "test-token-material");
|
||||
assert.equal(runnerEnvValue(manifest, "HWLAB_CLOUD_API_URL"), "http://cloud.test");
|
||||
assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assertNoSecretLeak(created);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => serverWithKubectl.server.close(() => resolve()));
|
||||
}
|
||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env"] };
|
||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
@@ -108,6 +116,27 @@ function runnerEnvValue(manifest: JsonRecord, name: string): unknown {
|
||||
return env.find((item) => item.name === name)?.value;
|
||||
}
|
||||
|
||||
function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string, secretName: string, secretKey: string): void {
|
||||
const manifest = rendered.manifest as JsonRecord;
|
||||
const spec = manifest.spec as JsonRecord;
|
||||
const template = spec.template as JsonRecord;
|
||||
const podSpec = template.spec as JsonRecord;
|
||||
const containers = podSpec.containers as JsonRecord[];
|
||||
const runner = containers[0] as JsonRecord;
|
||||
const env = runner.env as JsonRecord[];
|
||||
const entry = env.find((item) => item.name === envName) as JsonRecord | undefined;
|
||||
assert.ok(entry, `${envName} env should be projected from a SecretRef`);
|
||||
assert.equal(entry.value, undefined);
|
||||
const valueFrom = entry.valueFrom as JsonRecord;
|
||||
const secretKeyRef = valueFrom.secretKeyRef as JsonRecord;
|
||||
assert.equal(secretKeyRef.name, secretName);
|
||||
assert.equal(secretKeyRef.key, secretKey);
|
||||
|
||||
const summary = rendered.toolCredentials as JsonRecord;
|
||||
assert.equal(summary.valuesPrinted, false);
|
||||
assert.equal(summary.count, 1);
|
||||
}
|
||||
|
||||
function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void {
|
||||
const spec = manifest.spec as JsonRecord;
|
||||
const template = spec.template as JsonRecord;
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface SelfTestResult {
|
||||
|
||||
export type SelfTestCase = (context: SelfTestContext) => Promise<SelfTestResult> | SelfTestResult;
|
||||
|
||||
type SelfTestRunContext = Pick<SelfTestContext, "workspace" | "codexHome"> & Partial<Pick<SelfTestContext, "deepseekHome">> & { backendProfile?: BackendProfile; includeOnlyProfile?: BackendProfile; toolCredentials?: JsonRecord[] };
|
||||
|
||||
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");
|
||||
@@ -52,7 +54,7 @@ export async function createSelfTestContext(root: string): Promise<SelfTestConte
|
||||
};
|
||||
}
|
||||
|
||||
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 }> {
|
||||
export async function createRunWithCommand(client: ManagerClient, context: SelfTestRunContext, prompt: string, idempotencyKey: string, timeoutMs: number): Promise<{ runId: string; commandId: string }> {
|
||||
const backendProfile = context.backendProfile ?? "codex";
|
||||
const run = await client.post("/api/v1/runs", {
|
||||
tenantId: "unidesk",
|
||||
@@ -65,7 +67,7 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick<
|
||||
approval: "never",
|
||||
timeoutMs,
|
||||
network: "default",
|
||||
secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile) },
|
||||
secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile), ...toolCredentialScope(context) },
|
||||
},
|
||||
traceSink: null,
|
||||
}) as { id: string };
|
||||
@@ -75,6 +77,10 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick<
|
||||
return { runId: run.id, commandId: command.id };
|
||||
}
|
||||
|
||||
function toolCredentialScope(context: { toolCredentials?: JsonRecord[] }): JsonRecord {
|
||||
return context.toolCredentials ? { toolCredentials: context.toolCredentials } : {};
|
||||
}
|
||||
|
||||
function providerCredentials(context: Pick<SelfTestContext, "codexHome"> & Partial<Pick<SelfTestContext, "deepseekHome">> & { includeOnlyProfile?: BackendProfile }, backendProfile: BackendProfile): JsonRecord[] {
|
||||
const profiles: BackendProfile[] = context.includeOnlyProfile ? [context.includeOnlyProfile] : [backendProfile];
|
||||
return profiles.map((profile) => ({
|
||||
|
||||
Reference in New Issue
Block a user