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
|
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" \
|
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 ./
|
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" \
|
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 验证。 |
|
| `ProfileRef` | 已实现/已通过主闭环 | `codex` 与 `deepseek` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证。 |
|
||||||
| `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 |
|
| `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 摘要。 |
|
| `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 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`。 |
|
| 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 密钥管理流程完成。 |
|
| 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) 人工抽查。 |
|
| 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,后续单独更新规格。 |
|
| 外部 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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;
|
profile: BackendProfile | string;
|
||||||
secretRef: SecretRef;
|
secretRef: SecretRef;
|
||||||
}>;
|
}>;
|
||||||
|
toolCredentials?: Array<{
|
||||||
|
tool: string;
|
||||||
|
purpose?: string;
|
||||||
|
secretRef: SecretRef;
|
||||||
|
projection: {
|
||||||
|
kind: "env";
|
||||||
|
envName: string;
|
||||||
|
secretKey?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
allowCredentialEcho?: false;
|
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 });
|
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 };
|
const secretScopeResult: ExecutionPolicy["secretScope"] = { allowCredentialEcho: false };
|
||||||
if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable<ExecutionPolicy["secretScope"]["providerCredentials"]>;
|
if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable<ExecutionPolicy["secretScope"]["providerCredentials"]>;
|
||||||
|
if (toolCredentials.length > 0) secretScopeResult.toolCredentials = toolCredentials;
|
||||||
return {
|
return {
|
||||||
sandbox: requiredString(record, "sandbox"),
|
sandbox: requiredString(record, "sandbox"),
|
||||||
approval: requiredString(record, "approval"),
|
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 {
|
function validateSecretRef(record: JsonRecord): SecretRef {
|
||||||
const name = requiredString(record, "name");
|
const name = requiredString(record, "name");
|
||||||
const result: SecretRef = { 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 { isTerminalCommandState, isTerminalRunStatus, summarizeResourceBundleRef, summarizeSessionRef } from "./store.js";
|
||||||
import type { AgentRunStore } from "./store.js";
|
import type { AgentRunStore } from "./store.js";
|
||||||
import type { JsonRecord } from "../common/types.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 { renderRunnerJobManifest } from "../runner/k8s-job.js";
|
||||||
import type { RunnerTransientEnv } 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}`,
|
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 })),
|
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),
|
transientEnv: summarizeTransientEnv(transientEnv),
|
||||||
retention: {
|
retention: {
|
||||||
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
||||||
@@ -148,6 +149,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
|||||||
jobName: saved.jobName,
|
jobName: saved.jobName,
|
||||||
idempotencyKey: idempotencyKey ? "present" : null,
|
idempotencyKey: idempotencyKey ? "present" : null,
|
||||||
transientEnv: summarizeTransientEnv(transientEnv),
|
transientEnv: summarizeTransientEnv(transientEnv),
|
||||||
|
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||||
sessionRef: summarizeSessionRef(run.sessionRef ?? null),
|
sessionRef: summarizeSessionRef(run.sessionRef ?? null),
|
||||||
resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? 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 });
|
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 record = entry as JsonRecord;
|
||||||
const name = stringField(record, "name");
|
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 });
|
if (seen.has(name)) throw new AgentRunError("schema-invalid", `transientEnv name ${name} is duplicated`, { httpStatus: 400 });
|
||||||
seen.add(name);
|
seen.add(name);
|
||||||
const rawValue = record.value;
|
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 {
|
function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord {
|
||||||
return {
|
return {
|
||||||
count: items.length,
|
count: items.length,
|
||||||
|
|||||||
+55
-4
@@ -33,6 +33,14 @@ interface CredentialProjection {
|
|||||||
projectionMountPath: string;
|
projectionMountPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToolCredentialProjection {
|
||||||
|
tool: string;
|
||||||
|
purpose: string | null;
|
||||||
|
secretRef: SecretRef;
|
||||||
|
envName: string;
|
||||||
|
secretKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord {
|
export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord {
|
||||||
const render = renderRunnerJobManifest({ ...options, dryRun: true });
|
const render = renderRunnerJobManifest({ ...options, dryRun: true });
|
||||||
const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []);
|
const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []);
|
||||||
@@ -56,6 +64,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
|
|||||||
sourceCommit: render.sourceCommit,
|
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 })),
|
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 ?? []),
|
transientEnv: summarizeTransientEnv(options.transientEnv ?? []),
|
||||||
retention: {
|
retention: {
|
||||||
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
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 namespace = options.namespace ?? "agentrun-v01";
|
||||||
const attemptId = options.attemptId ?? `attempt_${Date.now().toString(36)}`;
|
const attemptId = options.attemptId ?? `attempt_${Date.now().toString(36)}`;
|
||||||
const runnerId = options.runnerId ?? `runner_${shortHash(`${options.run.id}:${attemptId}:${options.commandId}`)}`;
|
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 ttlSecondsAfterFinished = options.ttlSecondsAfterFinished ?? 86_400;
|
||||||
const jobName = `agentrun-v01-runner-${shortDnsHash(options.run.id, attemptId)}`;
|
const jobName = `agentrun-v01-runner-${shortDnsHash(options.run.id, attemptId)}`;
|
||||||
const secretRefs = credentialProjections(options.run, namespace);
|
const secretRefs = credentialProjections(options.run, namespace);
|
||||||
|
const toolCredentials = toolCredentialProjections(options.run, namespace);
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
if (secretRefs.length === 0) warnings.push("run executionPolicy.secretScope 未声明 provider SecretRef;runner 将按 secret-unavailable 上报,而不会降级直连外部凭据");
|
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 = {
|
const manifest: JsonRecord = {
|
||||||
apiVersion: "batch/v1",
|
apiVersion: "batch/v1",
|
||||||
kind: "Job",
|
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 selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile);
|
||||||
const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile);
|
const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile);
|
||||||
return [
|
return [
|
||||||
@@ -161,10 +171,23 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
|
|||||||
{ name: "HOME", value: "/home/agentrun" },
|
{ name: "HOME", value: "/home/agentrun" },
|
||||||
{ name: "CODEX_HOME", value: codexHome },
|
{ name: "CODEX_HOME", value: codexHome },
|
||||||
...(selectedSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: selectedSecret.projectionMountPath }] : []),
|
...(selectedSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: selectedSecret.projectionMountPath }] : []),
|
||||||
|
...toolCredentialEnvVars(context.toolCredentials),
|
||||||
...transientEnvVars(options.transientEnv ?? []),
|
...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[] {
|
function transientEnvVars(items: RunnerTransientEnv[]): JsonRecord[] {
|
||||||
return items.map((item) => ({ name: item.name, value: item.value }));
|
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 {
|
function redactTransientEnvInManifest(manifest: JsonRecord, items: RunnerTransientEnv[]): JsonRecord {
|
||||||
if (items.length === 0) return manifest;
|
if (items.length === 0) return manifest;
|
||||||
const names = new Set(items.map((item) => item.name));
|
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 {
|
function secretVolume(item: CredentialProjection): JsonRecord {
|
||||||
const secret: JsonRecord = {
|
const secret: JsonRecord = {
|
||||||
secretName: item.secretRef.name,
|
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() });
|
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: new MemoryAgentRunStore() });
|
||||||
try {
|
try {
|
||||||
const client = new ManagerClient(server.baseUrl);
|
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({
|
const rendered = renderRunnerJobDryRun({
|
||||||
run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord,
|
run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord,
|
||||||
commandId: item.commandId,
|
commandId: item.commandId,
|
||||||
@@ -27,6 +33,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal(((rendered.retention as JsonRecord).ttlSecondsAfterFinished), 86_400);
|
assert.equal(((rendered.retention as JsonRecord).ttlSecondsAfterFinished), 86_400);
|
||||||
assert.equal((rendered.jobIdentity as { serviceAccountName?: string }).serviceAccountName, "agentrun-v01-runner");
|
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");
|
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.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"]);
|
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_DEVICE_POD_SESSION_TOKEN"]);
|
||||||
assertNoSecretLeak(rendered);
|
assertNoSecretLeak(rendered);
|
||||||
@@ -70,7 +77,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const jobClient = new ManagerClient(serverWithKubectl.baseUrl);
|
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`, {
|
const created = await jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, {
|
||||||
commandId: jobItem.commandId,
|
commandId: jobItem.commandId,
|
||||||
attemptId: "attempt_selftest_create",
|
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((manifest.spec as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
||||||
assert.equal(runnerEnvValue(manifest, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "test-token-material");
|
assert.equal(runnerEnvValue(manifest, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "test-token-material");
|
||||||
assert.equal(runnerEnvValue(manifest, "HWLAB_CLOUD_API_URL"), "http://cloud.test");
|
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);
|
assertNoSecretLeak(created);
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve) => serverWithKubectl.server.close(() => resolve()));
|
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 {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
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;
|
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 {
|
function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void {
|
||||||
const spec = manifest.spec as JsonRecord;
|
const spec = manifest.spec as JsonRecord;
|
||||||
const template = spec.template as JsonRecord;
|
const template = spec.template as JsonRecord;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface SelfTestResult {
|
|||||||
|
|
||||||
export type SelfTestCase = (context: SelfTestContext) => Promise<SelfTestResult> | 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> {
|
export async function createSelfTestContext(root: string): Promise<SelfTestContext> {
|
||||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-"));
|
const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-"));
|
||||||
const codexHome = path.join(tmp, "codex-home");
|
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 backendProfile = context.backendProfile ?? "codex";
|
||||||
const run = await client.post("/api/v1/runs", {
|
const run = await client.post("/api/v1/runs", {
|
||||||
tenantId: "unidesk",
|
tenantId: "unidesk",
|
||||||
@@ -65,7 +67,7 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick<
|
|||||||
approval: "never",
|
approval: "never",
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
network: "default",
|
network: "default",
|
||||||
secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile) },
|
secretScope: { allowCredentialEcho: false, providerCredentials: providerCredentials(context, backendProfile), ...toolCredentialScope(context) },
|
||||||
},
|
},
|
||||||
traceSink: null,
|
traceSink: null,
|
||||||
}) as { id: string };
|
}) as { id: string };
|
||||||
@@ -75,6 +77,10 @@ export async function createRunWithCommand(client: ManagerClient, context: Pick<
|
|||||||
return { runId: run.id, commandId: command.id };
|
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[] {
|
function providerCredentials(context: Pick<SelfTestContext, "codexHome"> & Partial<Pick<SelfTestContext, "deepseekHome">> & { includeOnlyProfile?: BackendProfile }, backendProfile: BackendProfile): JsonRecord[] {
|
||||||
const profiles: BackendProfile[] = context.includeOnlyProfile ? [context.includeOnlyProfile] : [backendProfile];
|
const profiles: BackendProfile[] = context.includeOnlyProfile ? [context.includeOnlyProfile] : [backendProfile];
|
||||||
return profiles.map((profile) => ({
|
return profiles.map((profile) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user