fix: 物化 Artificer 主工作区

This commit is contained in:
root
2026-07-12 05:17:04 +02:00
parent 9ec8e24c82
commit b435cc494a
10 changed files with 495 additions and 57 deletions
+2
View File
@@ -26,6 +26,8 @@ spec:
workspaceRef:
kind: opaque
path: .
repo: pikasTech/unidesk
branch: master
executionPolicy:
sandbox: workspace-write
approval: never
+5 -11
View File
@@ -2,10 +2,10 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { AgentRunError } from "./errors.js";
import type { AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef, WorkspaceRef } from "./types.js";
import type { AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef } from "./types.js";
import { backendProfileSpec, isBackendProfile } from "./backend-profiles.js";
import { imageRefSourceSummary, validateAipodImageRef } from "./env-image-ref.js";
import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validateResourceBundleRef, validateSessionRef } from "./validation.js";
import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validatePrimaryWorkspaceContract, validateResourceBundleRef, validateSessionRef, validateWorkspaceRef } from "./validation.js";
const aipodApiVersion = "agentrun.pikastech.local/v0.1";
const aipodKind = "AipodSpec";
@@ -80,6 +80,8 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
const executionPolicy = validateExecutionPolicy(asRecord(spec.executionPolicy, "aipodSpec.spec.executionPolicy"));
validateAipodProviderCredential(backendProfile, executionPolicy);
const resourceBundleRef = validateResourceBundleRef(spec.resourceBundleRef);
const workspaceRef = isJsonRecord(spec.workspaceRef) ? validateWorkspaceRef(spec.workspaceRef) : undefined;
validatePrimaryWorkspaceContract(workspaceRef ?? null, resourceBundleRef);
const result: AipodSpec = {
apiVersion: aipodApiVersion,
kind: aipodKind,
@@ -99,7 +101,7 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
backendProfile,
...(isJsonRecord(spec.model) ? { model: spec.model } : {}),
imageRef,
...(isJsonRecord(spec.workspaceRef) ? { workspaceRef: validateWorkspaceRef(spec.workspaceRef) } : {}),
...(workspaceRef ? { workspaceRef } : {}),
...(spec.sessionRef !== undefined ? { sessionRef: validateSessionRef(spec.sessionRef) } : {}),
executionPolicy,
resourceBundleRef,
@@ -225,14 +227,6 @@ function defaultAipodSessionId(record: AipodSpecRecord, input: RenderAipodInput,
return `sess_${name}_${suffix}`;
}
function validateWorkspaceRef(record: JsonRecord): WorkspaceRef {
const kind = requiredString(record, "kind");
if (!["git-worktree", "host-path", "kubernetes-pvc", "opaque"].includes(kind)) {
throw new AgentRunError("schema-invalid", `workspaceRef.kind ${kind} is not supported`, { httpStatus: 400 });
}
return record as WorkspaceRef;
}
function summarizeProviderCredentials(policy: ExecutionPolicy): JsonRecord {
const items = (policy.secretScope.providerCredentials ?? []).map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], valuesPrinted: false }));
return { count: items.length, profiles: items.map((item) => item.profile), items, valuesPrinted: false };
+73 -8
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import type { BackendProfile, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue, QueueTaskState, ResourceBundleRef, SecretRef, SessionListState, SessionRef } from "./types.js";
import type { BackendProfile, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue, QueueTaskState, ResourceBundleRef, SecretRef, SessionListState, SessionRef, WorkspaceRef } from "./types.js";
import { AgentRunError } from "./errors.js";
import { backendProfileIdPattern, backendProfileSpec, isBackendProfile } from "./backend-profiles.js";
@@ -50,12 +50,15 @@ export function validateCreateRun(input: unknown): CreateRunInput {
const backendProfile = backendProfileValue as BackendProfile;
const executionPolicy = validateExecutionPolicy(requiredRecord(record, "executionPolicy"));
validateBackendSecretScope(backendProfile, executionPolicy);
const workspaceRef = validateWorkspaceRef(requiredRecord(record, "workspaceRef"));
const resourceBundleRef = validateResourceBundleRef(record.resourceBundleRef);
validatePrimaryWorkspaceContract(workspaceRef, resourceBundleRef);
return {
tenantId,
projectId: requiredString(record, "projectId"),
workspaceRef: requiredRecord(record, "workspaceRef") as CreateRunInput["workspaceRef"],
workspaceRef,
sessionRef: validateSessionRef(record.sessionRef),
resourceBundleRef: validateResourceBundleRef(record.resourceBundleRef),
resourceBundleRef,
providerId: requiredString(record, "providerId"),
backendProfile,
executionPolicy,
@@ -88,6 +91,7 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
const commitId = optionalString(record.commitId);
if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId");
const ref = validateGitRef(record.ref, "resourceBundleRef.ref");
if (!commitId && !ref) throw new AgentRunError("schema-invalid", "resourceBundleRef must declare ref or a full commitId", { httpStatus: 400 });
rejectLegacyResourceBundleFields(record);
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) };
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
@@ -111,25 +115,83 @@ function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defa
if (value.length === 0) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at least one entry", { httpStatus: 400 });
if (value.length > 64) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at most 64 entries", { httpStatus: 400 });
const seen = new Set<string>();
const seenTargets = new Set<string>();
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.bundles[${index}]`);
const name = optionalString(record.name);
if (name) validateResourceName(name, `resourceBundleRef.bundles[${index}].name`);
const repoUrl = optionalString(record.repoUrl) ?? defaultRepoUrl;
const commitId = optionalString(record.commitId) ?? defaultCommitId;
const explicitCommitId = optionalString(record.commitId);
const explicitRef = validateGitRef(record.ref, `resourceBundleRef.bundles[${index}].ref`);
if (repoUrl !== defaultRepoUrl && !explicitCommitId && !explicitRef) {
throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] uses a different repoUrl and must declare its own ref or commitId`, { httpStatus: 400 });
}
const commitId = explicitCommitId ?? (repoUrl === defaultRepoUrl ? defaultCommitId : undefined);
if (commitId) validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`);
const ref = validateGitRef(record.ref, `resourceBundleRef.bundles[${index}].ref`) ?? (commitId ? undefined : defaultRef);
const ref = explicitRef ?? (repoUrl === defaultRepoUrl ? defaultRef : undefined);
const subpath = validateBundleSubpath(requiredString(record, "subpath"), `resourceBundleRef.bundles[${index}].subpath`);
const rawTargetPath = typeof record.targetPath === "string" ? record.targetPath : record.target_path;
if (typeof rawTargetPath !== "string" || rawTargetPath.trim().length === 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path is required`, { httpStatus: 400 });
const targetPath = validateWorkspaceRelativePath(rawTargetPath.trim(), `resourceBundleRef.bundles[${index}].target_path`);
if (targetPath === ".git" || targetPath.startsWith(".git/")) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path cannot replace primary workspace Git metadata`, { httpStatus: 400 });
if (seenTargets.has(targetPath)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path conflicts with an earlier bundle target`, { httpStatus: 400 });
seenTargets.add(targetPath);
const identity = `${repoUrl}\0${commitId ?? ""}\0${ref ?? ""}\0${subpath}\0${targetPath}`;
if (seen.has(identity)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] duplicates an earlier bundle target`, { httpStatus: 400 });
seen.add(identity);
return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId && commitId !== defaultCommitId ? { commitId } : {}), ...(ref && ref !== defaultRef ? { ref } : {}), subpath, targetPath };
return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId && (repoUrl !== defaultRepoUrl || commitId !== defaultCommitId) ? { commitId } : {}), ...(ref && (repoUrl !== defaultRepoUrl || ref !== defaultRef) ? { ref } : {}), subpath, targetPath };
});
}
export function validateWorkspaceRef(record: JsonRecord): WorkspaceRef {
const kind = requiredString(record, "kind");
if (!["git-worktree", "host-path", "kubernetes-pvc", "opaque"].includes(kind)) {
throw new AgentRunError("schema-invalid", `workspaceRef.kind ${kind} is not supported`, { httpStatus: 400 });
}
return record as WorkspaceRef;
}
export function validatePrimaryWorkspaceContract(workspaceRef: WorkspaceRef | null, resourceBundleRef: ResourceBundleRef | null): void {
if (!resourceBundleRef) return;
if (!workspaceRef) throw new AgentRunError("schema-invalid", "resourceBundleRef requires workspaceRef", { httpStatus: 400 });
if (workspaceRef.kind !== "opaque") {
throw new AgentRunError("schema-invalid", "gitbundle primary workspace requires workspaceRef.kind=opaque so the resource bundle remains the only source authority", { httpStatus: 400 });
}
if (workspaceRef.path !== ".") {
throw new AgentRunError("schema-invalid", "gitbundle primary workspace requires workspaceRef.path=. as its declared targetPath", { httpStatus: 400 });
}
const workspaceRepo = normalizedRepoIdentity(workspaceRef.repo);
const sourceRepo = normalizedRepoIdentity(resourceBundleRef.repoUrl);
if (workspaceRepo && sourceRepo && workspaceRepo !== sourceRepo) {
throw new AgentRunError("schema-invalid", "workspaceRef.repo does not match resourceBundleRef.repoUrl", { httpStatus: 400, details: { workspaceRepo, sourceRepo, valuesPrinted: false } });
}
const workspaceBranch = optionalString(workspaceRef.branch);
if (workspaceBranch && resourceBundleRef.ref && normalizeBranchRef(workspaceBranch) !== normalizeBranchRef(resourceBundleRef.ref)) {
throw new AgentRunError("schema-invalid", "workspaceRef.branch does not match resourceBundleRef.ref", { httpStatus: 400, details: { workspaceBranch, resourceRef: resourceBundleRef.ref, valuesPrinted: false } });
}
}
function normalizedRepoIdentity(value: unknown): string | null {
const raw = optionalString(value);
if (!raw) return null;
const plain = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/u.exec(raw);
if (plain) return `${plain[1]}/${plain[2]}`.toLowerCase();
const scp = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/u.exec(raw);
if (scp) return `${scp[1]}/${scp[2]}`.toLowerCase();
try {
const parsed = new URL(raw);
if (parsed.hostname.toLowerCase() !== "github.com") return null;
const parts = parsed.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "").split("/");
return parts.length === 2 ? `${parts[0]}/${parts[1]}`.toLowerCase() : null;
} catch {
return null;
}
}
function normalizeBranchRef(value: string): string {
return value.replace(/^refs\/heads\//u, "");
}
function validateCommitId(commitId: string, fieldName: string): void {
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", `${fieldName} must be a full 40-character git commit sha`, { httpStatus: 400 });
}
@@ -437,6 +499,9 @@ export function validateCreateQueueTask(input: unknown): CreateQueueTaskInput {
if (typeof priorityValue !== "number" || !Number.isFinite(priorityValue)) throw new AgentRunError("schema-invalid", "priority must be a finite number", { httpStatus: 400 });
const referencesValue = record.references ?? [];
if (!Array.isArray(referencesValue)) throw new AgentRunError("schema-invalid", "references must be an array", { httpStatus: 400 });
const workspaceRef = record.workspaceRef === undefined || record.workspaceRef === null ? null : validateWorkspaceRef(requiredRecord(record, "workspaceRef"));
const resourceBundleRef = validateResourceBundleRef(record.resourceBundleRef);
validatePrimaryWorkspaceContract(workspaceRef, resourceBundleRef);
const result: CreateQueueTaskInput = {
tenantId,
projectId: requiredString(record, "projectId"),
@@ -446,10 +511,10 @@ export function validateCreateQueueTask(input: unknown): CreateQueueTaskInput {
priority: priorityValue,
backendProfile: backendProfileValue,
providerId: optionalString(record.providerId) ?? null,
workspaceRef: record.workspaceRef === undefined || record.workspaceRef === null ? null : requiredRecord(record, "workspaceRef") as CreateQueueTaskInput["workspaceRef"],
workspaceRef,
sessionRef: validateSessionRef(record.sessionRef),
executionPolicy: record.executionPolicy === undefined || record.executionPolicy === null ? null : validateExecutionPolicy(requiredRecord(record, "executionPolicy")),
resourceBundleRef: validateResourceBundleRef(record.resourceBundleRef),
resourceBundleRef,
payload: record.payload === undefined ? {} : asRecord(record.payload, "payload"),
references: referencesValue.map((item, index) => asRecord(item, `references[${index}]`)),
metadata: record.metadata === undefined ? {} : asRecord(record.metadata, "metadata"),
+30 -4
View File
@@ -120,6 +120,12 @@ interface ToolCredentialVolumeProjection extends ToolCredentialBaseProjection {
mountPath: string;
}
interface ResourceCredentialProjection {
secretRef: SecretRef;
volumeName: string;
mountPath: string;
}
export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord {
const render = renderRunnerJobManifest({ ...options, dryRun: true });
const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []);
@@ -145,6 +151,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
runnerApiKeySecretRef: { ...options.runnerApiKeySecretRef, 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),
resourceCredential: summarizeResourceCredential(render.resourceCredential, render.namespace),
gitTransport: gitTransportSummary(),
transientEnv: summarizeTransientEnv(options.transientEnv ?? []),
workReady: staticWorkReadyCapabilitySummary(),
@@ -162,7 +169,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
};
}
export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerJobId: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; warnings: string[]; ttlSecondsAfterFinished: number; ttlPolicy: JsonRecord; runnerIdleTimeoutMs: number; backendRetryMaxAttempts: number; backendRetryInitialBackoffMs: number; backendRetryMaxBackoffMs: number } {
export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerJobId: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; resourceCredential: ResourceCredentialProjection | null; warnings: string[]; ttlSecondsAfterFinished: number; ttlPolicy: JsonRecord; runnerIdleTimeoutMs: number; backendRetryMaxAttempts: number; backendRetryInitialBackoffMs: number; backendRetryMaxBackoffMs: 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}`)}`;
@@ -182,9 +189,10 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
const jobName = `${jobNamePrefix}-${shortDnsHash(options.run.id, attemptId)}`;
const secretRefs = credentialProjections(options.run, namespace);
const toolCredentials = toolCredentialProjections(options.run, namespace);
const resourceCredential = resourceCredentialProjection(options.run, namespace);
const sessionPvc = options.sessionPvc;
if (secretRefs.length === 0) warnings.push("run executionPolicy.secretScope 未声明 provider SecretRefrunner 将按 secret-unavailable 上报,而不会降级直连外部凭据");
const env = runnerEnv(options, { namespace, jobName, runnerJobId, runnerId, attemptId, sourceCommit, runnerApiKeySecretRef, secretRefs, toolCredentials, sessionPvc, runnerIdleTimeoutMs, backendRetryMaxAttempts, backendRetryInitialBackoffMs, backendRetryMaxBackoffMs });
const env = runnerEnv(options, { namespace, jobName, runnerJobId, runnerId, attemptId, sourceCommit, runnerApiKeySecretRef, secretRefs, toolCredentials, resourceCredential, sessionPvc, runnerIdleTimeoutMs, backendRetryMaxAttempts, backendRetryInitialBackoffMs, backendRetryMaxBackoffMs });
const manifest: JsonRecord = {
apiVersion: "batch/v1",
kind: "Job",
@@ -234,6 +242,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
{ name: "runner-home", mountPath: "/home/agentrun" },
...secretRefs.map((item) => ({ name: item.volumeName, mountPath: item.projectionMountPath, readOnly: true })),
...toolCredentialVolumeMounts(toolCredentials),
...(resourceCredential ? [{ name: resourceCredential.volumeName, mountPath: resourceCredential.mountPath, readOnly: true }] : []),
...(sessionPvc ? [{ name: "agentrun-sessions", mountPath: sessionPvc.mountPath, readOnly: false }] : []),
],
resources: {
@@ -251,16 +260,17 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
{ name: "runner-home", emptyDir: {} },
...secretRefs.map(secretVolume),
...toolCredentialVolumes(toolCredentials),
...(resourceCredential ? [secretVolume({ profile: "resource-git", secretRef: resourceCredential.secretRef, volumeName: resourceCredential.volumeName, runtimeMountPath: resourceCredential.mountPath, projectionMountPath: resourceCredential.mountPath })] : []),
...(sessionPvc ? [{ name: "agentrun-sessions", persistentVolumeClaim: { claimName: sessionPvc.pvcName } }] : []),
],
},
},
},
};
return { manifest, namespace, jobName, runnerJobId, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, toolCredentials, warnings, ttlSecondsAfterFinished, ttlPolicy, runnerIdleTimeoutMs, backendRetryMaxAttempts, backendRetryInitialBackoffMs, backendRetryMaxBackoffMs };
return { manifest, namespace, jobName, runnerJobId, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, toolCredentials, resourceCredential, warnings, ttlSecondsAfterFinished, ttlPolicy, runnerIdleTimeoutMs, backendRetryMaxAttempts, backendRetryInitialBackoffMs, backendRetryMaxBackoffMs };
}
function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerJobId: string; runnerId: string; attemptId: string; sourceCommit: string; runnerApiKeySecretRef: RunnerApiKeySecretRef; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; sessionPvc: RunnerSessionPvcOptions | undefined; runnerIdleTimeoutMs: number; backendRetryMaxAttempts: number; backendRetryInitialBackoffMs: number; backendRetryMaxBackoffMs: number }): JsonRecord[] {
function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerJobId: string; runnerId: string; attemptId: string; sourceCommit: string; runnerApiKeySecretRef: RunnerApiKeySecretRef; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; resourceCredential: ResourceCredentialProjection | null; sessionPvc: RunnerSessionPvcOptions | undefined; runnerIdleTimeoutMs: number; backendRetryMaxAttempts: number; backendRetryInitialBackoffMs: number; backendRetryMaxBackoffMs: number }): JsonRecord[] {
const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile);
const codexHome = runnerCodexHome(options.run.backendProfile, selectedSecret, context.sessionPvc);
const bootRepoUrl = optionalString(options.bootRepoUrl) ?? defaultBootRepoUrl;
@@ -277,6 +287,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
{ name: "AGENTRUN_CODEX_SHELL_SANDBOX", value: codexShellSandbox(options.run.executionPolicy) },
{ name: "AGENTRUN_SESSION_REF_JSON", value: JSON.stringify(options.run.sessionRef ?? null) },
{ name: "AGENTRUN_RESOURCE_BUNDLE_JSON", value: JSON.stringify(options.run.resourceBundleRef ?? null) },
...(context.resourceCredential ? [{ name: "AGENTRUN_RESOURCE_CREDENTIAL_PATH", value: context.resourceCredential.mountPath }] : []),
{ name: "AGENTRUN_WORKSPACE_ROOT", value: "/home/agentrun/workspaces" },
{ name: "AGENTRUN_RESOURCE_BIN_PATH", value: defaultResourceBinPath },
{ name: "AGENTRUN_SOURCE_COMMIT", value: context.sourceCommit },
@@ -534,6 +545,11 @@ function summarizeToolCredentials(items: ToolCredentialProjection[], namespace:
};
}
function summarizeResourceCredential(item: ResourceCredentialProjection | null, namespace: string): JsonRecord {
if (!item) return { present: false, name: null, namespace: null, keys: [], mountPath: null, valuesPrinted: false };
return { present: true, name: item.secretRef.name, namespace: item.secretRef.namespace ?? namespace, keys: item.secretRef.keys ?? [], mountPath: item.mountPath, valuesPrinted: false };
}
function redactTransientEnvInManifest(manifest: JsonRecord, items: RunnerTransientEnv[]): JsonRecord {
if (items.length === 0) return manifest;
const names = new Set(items.map((item) => item.name));
@@ -580,6 +596,16 @@ function toolCredentialProjections(run: RunRecord, namespace: string): ToolCrede
});
}
function resourceCredentialProjection(run: RunRecord, namespace: string): ResourceCredentialProjection | null {
const secretRef = run.resourceBundleRef?.credentialRef;
if (!secretRef) return null;
return {
secretRef: secretRef.namespace ? secretRef : { ...secretRef, namespace },
volumeName: "resource-git-credential",
mountPath: "/var/run/agentrun/resource-git",
};
}
function secretVolume(item: CredentialProjection): JsonRecord {
const secret: JsonRecord = {
secretName: item.secretRef.name,
+150 -19
View File
@@ -1,10 +1,10 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { chmod, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { AgentRunError } from "../common/errors.js";
import { redactText } from "../common/redaction.js";
import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef } from "../common/types.js";
import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef, WorkspaceRef } from "../common/types.js";
import { defaultGitDirectHosts, defaultGitHttpVersion, defaultGitLowSpeedLimitBytes, defaultGitLowSpeedTimeSeconds, defaultGitOperationTimeoutMs, gitTransportSummary } from "../common/git-transport.js";
import { stableHash } from "../common/validation.js";
import { bundledWorkReadyTools } from "../common/work-ready.js";
@@ -54,6 +54,7 @@ interface GitCheckout {
requestedRef?: string;
checkoutPath: string;
treeId: string;
branch: string | null;
}
interface GitMirrorConfig {
@@ -66,6 +67,7 @@ interface GitBundleSource {
ref?: string;
gitMirror?: GitMirrorConfig;
credentialRefPresent: boolean;
credentialEnv?: NodeJS.ProcessEnv;
}
interface GitBundleSourceContext extends JsonRecord {
@@ -94,8 +96,9 @@ interface MaterializedGitBundle {
sourceBytes: number | null;
}
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, workspaceRef: WorkspaceRef, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
if (!resourceBundleRef) return null;
assertPrimaryWorkspaceRef(workspaceRef, resourceBundleRef);
const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces");
const runScope = env.AGENTRUN_RUN_ID ?? env.AGENTRUN_ATTEMPT_ID ?? "standalone";
const assemblyRoot = path.join(workspaceRoot, `gitbundle-${stableHash({ runScope, resourceBundleRef }).slice(0, 16)}`);
@@ -107,9 +110,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
}
await rm(assemblyRoot, { recursive: true, force: true });
await mkdir(checkoutRoot, { recursive: true });
await mkdir(workspacePath, { recursive: true });
const gitMirror = gitMirrorConfig(resourceBundleRef, env);
const defaultSource = defaultGitBundleSource(resourceBundleRef, env, gitMirror);
const credentialEnv = await resourceCredentialEnv(resourceBundleRef, env);
const defaultSource = defaultGitBundleSource(resourceBundleRef, env, gitMirror, credentialEnv);
const checkoutCache = new Map<string, Promise<GitCheckout>>();
const checkoutFor = (source: GitBundleSource, sourceContext: GitBundleSourceContext) => {
const key = stableHash(gitSourceIdentity(source));
@@ -121,7 +124,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
return checkout;
};
const defaultCheckout = await checkoutFor(defaultSource, { kind: "resource-bundle", index: null, name: null });
await replacePrimaryWorkspace(defaultCheckout.checkoutPath, workspacePath, runScope);
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor);
const primaryWorkspace = await verifyPrimaryWorkspace(workspacePath, workspaceRef, defaultCheckout);
const tools = await prepareGitBundleTools(workspacePath, env);
const skills = await discoverGitBundleSkills(workspacePath, materializedBundles);
const requiredSkills = materializeRequiredSkills(resourceBundleRef.requiredSkills ?? [], skills.items);
@@ -147,6 +152,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
workspacePath: pathSummary(workspacePath),
workspacePersistence: explicitWorkspacePath ? { mode: "session", path: pathSummary(workspacePath), valuesPrinted: false } : { mode: "run", path: pathSummary(workspacePath), valuesPrinted: false },
primaryWorkspace,
gitTransport: gitTransportSummary(),
bundles: {
count: materializedBundles.length,
@@ -178,13 +184,13 @@ function defaultGitMirrorConfig(env: NodeJS.ProcessEnv): GitMirrorConfig {
return { baseUrl: normalizeMirrorBaseUrl(baseUrl) };
}
function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv, gitMirror?: GitMirrorConfig): GitBundleSource {
const ref = optionalNonEmpty(resourceBundleRef.ref) ?? optionalNonEmpty(env.AGENTRUN_RESOURCE_BUNDLE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_BRANCH);
const credentialRefPresent = resourceBundleRef.credentialRef !== undefined;
if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref, credentialRefPresent, ...(gitMirror ? { gitMirror } : {}) };
function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv, gitMirror?: GitMirrorConfig, credentialEnv?: NodeJS.ProcessEnv): GitBundleSource {
void env;
const ref = optionalNonEmpty(resourceBundleRef.ref);
const commitId = optionalNonEmpty(resourceBundleRef.commitId);
if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId, credentialRefPresent, ...(gitMirror ? { gitMirror } : {}) };
return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD", credentialRefPresent, ...(gitMirror ? { gitMirror } : {}) };
const credentialRefPresent = resourceBundleRef.credentialRef !== undefined;
if (!ref && !commitId) throw new AgentRunError("schema-invalid", "gitbundle primary source must declare ref or commitId", { httpStatus: 400 });
return { repoUrl: resourceBundleRef.repoUrl, ...(ref ? { ref } : {}), ...(commitId ? { commitId } : {}), credentialRefPresent, ...(gitMirror ? { gitMirror } : {}), ...(credentialEnv ? { credentialEnv } : {}) };
}
function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource {
@@ -192,13 +198,11 @@ function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceB
const ref = optionalNonEmpty(bundle.ref);
const mirror = defaultSource.gitMirror;
const credentialRefPresent = defaultSource.credentialRefPresent;
if (ref) return { repoUrl, ref, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
const credentialEnv = defaultSource.credentialEnv;
const commitId = optionalNonEmpty(bundle.commitId);
if (commitId) return { repoUrl, commitId, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
if (ref || commitId) return { repoUrl, ...(ref ? { ref } : {}), ...(commitId ? { commitId } : {}), credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}), ...(credentialEnv ? { credentialEnv } : {}) };
if (repoUrl === defaultSource.repoUrl) return defaultSource;
if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
return { repoUrl, ref: "HEAD", credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
throw new AgentRunError("schema-invalid", "cross-repository bundle must declare its own ref or commitId", { httpStatus: 400 });
}
async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource, sourceContext: GitBundleSourceContext): Promise<GitCheckout> {
@@ -213,14 +217,18 @@ async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource,
sourceRef,
sourceContext,
credentialRefPresent: source.credentialRefPresent,
...(source.credentialEnv ? { env: source.credentialEnv } : {}),
};
await mkdir(checkoutPath, { recursive: true });
await git(["init"], checkoutPath);
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
await git(["remote", "add", "origin", fetch.fetchRepoUrl], checkoutPath);
let branch: string | null = null;
if (source.ref) {
await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath, fetchOptions);
await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
branch = localBranchFromRef(source.ref);
if (branch) await git(["checkout", "-B", branch, "FETCH_HEAD"], checkoutPath);
else await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
} else if (source.commitId) {
await git(["fetch", "--depth", "1", "origin", source.commitId], checkoutPath, fetchOptions);
await git(["checkout", "--detach", source.commitId], checkoutPath);
@@ -230,13 +238,135 @@ async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource,
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
if (source.commitId && actualCommit !== source.commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: source.commitId, actualCommit } });
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
return { repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl, mirrorUsed: fetch.mirrorUsed, ...(fetch.mirrorBaseUrl ? { mirrorBaseUrl: fetch.mirrorBaseUrl } : {}), commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId };
await git(["remote", "set-url", "origin", source.repoUrl], checkoutPath);
if (branch) {
await git(["config", `branch.${branch}.remote`, "origin"], checkoutPath);
await git(["config", `branch.${branch}.merge`, `refs/heads/${branch}`], checkoutPath);
}
return { repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl, mirrorUsed: fetch.mirrorUsed, ...(fetch.mirrorBaseUrl ? { mirrorBaseUrl: fetch.mirrorBaseUrl } : {}), commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId, branch };
}
function gitSourceIdentity(source: GitBundleSource): JsonRecord {
return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, gitMirror: source.gitMirror ? { baseUrl: source.gitMirror.baseUrl } : null };
}
async function resourceCredentialEnv(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv | undefined> {
const credentialRef = resourceBundleRef.credentialRef;
if (!credentialRef) return undefined;
const credentialPath = optionalNonEmpty(env.AGENTRUN_RESOURCE_CREDENTIAL_PATH);
if (!credentialPath) {
throw new AgentRunError("secret-unavailable", "resource bundle credentialRef was declared but its runtime projection is missing", { httpStatus: 503, details: { credentialRef: { name: credentialRef.name, namespace: credentialRef.namespace ?? null, keys: credentialRef.keys ?? [], valuesPrinted: false }, valuesPrinted: false } });
}
const absolutePath = path.resolve(credentialPath);
try {
const info = await stat(absolutePath);
if (!info.isDirectory()) throw new Error("credential projection is not a directory");
for (const key of credentialRef.keys ?? []) await stat(path.join(absolutePath, key));
} catch (error) {
throw new AgentRunError("secret-unavailable", "resource bundle credential projection is incomplete", { httpStatus: 503, details: { credentialRef: { name: credentialRef.name, namespace: credentialRef.namespace ?? null, keys: credentialRef.keys ?? [], valuesPrinted: false }, projectionPath: pathSummary(absolutePath), error: fileErrorSummary(error), valuesPrinted: false } });
}
const keys = new Set(credentialRef.keys ?? []);
if (keys.has("id_ed25519") && keys.has("known_hosts")) {
const args = [
"ssh",
...(keys.has("config") ? ["-F", `${absolutePath}/config`] : []),
"-i", `${absolutePath}/id_ed25519`,
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", `UserKnownHostsFile=${absolutePath}/known_hosts`,
];
return { GIT_SSH_COMMAND: args.join(" ") };
}
return {};
}
function localBranchFromRef(ref: string): string | null {
const normalized = ref.replace(/^refs\/heads\//u, "");
if (normalized !== ref || (!ref.startsWith("refs/") && !/^[0-9a-f]{40}$/u.test(ref))) return normalized;
return null;
}
function assertPrimaryWorkspaceRef(workspaceRef: WorkspaceRef, resourceBundleRef: ResourceBundleRef): void {
if (workspaceRef.kind !== "opaque" || workspaceRef.path !== ".") {
throw new AgentRunError("schema-invalid", "gitbundle primary workspace requires workspaceRef.kind=opaque and workspaceRef.path=.", { httpStatus: 400 });
}
const expectedBranch = optionalNonEmpty(workspaceRef.branch);
if (expectedBranch && resourceBundleRef.ref && normalizeBranch(expectedBranch) !== normalizeBranch(resourceBundleRef.ref)) {
throw new AgentRunError("schema-invalid", "workspaceRef.branch does not match gitbundle primary ref", { httpStatus: 400, details: { expectedBranch, requestedRef: resourceBundleRef.ref, valuesPrinted: false } });
}
}
async function replacePrimaryWorkspace(checkoutPath: string, workspacePath: string, runScope: string): Promise<void> {
const parent = path.dirname(workspacePath);
const stage = path.join(parent, `.${path.basename(workspacePath)}.agentrun-stage-${stableHash({ runScope, workspacePath }).slice(0, 12)}`);
if (path.resolve(checkoutPath) === path.resolve(workspacePath)) return;
await mkdir(parent, { recursive: true });
await rm(stage, { recursive: true, force: true });
try {
await cp(checkoutPath, stage, { recursive: true, force: true, dereference: false });
await rm(workspacePath, { recursive: true, force: true });
await rename(stage, workspacePath);
} catch (error) {
await rm(stage, { recursive: true, force: true });
throw new AgentRunError("infra-failed", "primary workspace materialization failed", { httpStatus: 500, details: { stage: pathSummary(stage), workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } });
}
}
async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: WorkspaceRef, checkout: GitCheckout): Promise<JsonRecord> {
let rootStat;
let gitStat;
try {
rootStat = await stat(workspacePath);
gitStat = await stat(path.join(workspacePath, ".git"));
} catch (error) {
throw new AgentRunError("infra-failed", "primary workspace is missing its Git root", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } });
}
if (!rootStat.isDirectory() || (!gitStat.isDirectory() && !gitStat.isFile())) {
throw new AgentRunError("infra-failed", "primary workspace is not a materialized Git directory", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), valuesPrinted: false } });
}
const actualCommit = (await git(["rev-parse", "HEAD"], workspacePath)).stdout.trim();
if (actualCommit !== checkout.commitId) {
throw new AgentRunError("infra-failed", "primary workspace HEAD does not match materialized source commit", { httpStatus: 500, details: { expectedCommit: checkout.commitId, actualCommit, valuesPrinted: false } });
}
const branch = (await git(["branch", "--show-current"], workspacePath)).stdout.trim() || null;
if (checkout.branch && branch !== checkout.branch) {
throw new AgentRunError("infra-failed", "primary workspace branch does not match requested source ref", { httpStatus: 500, details: { expectedBranch: checkout.branch, actualBranch: branch, valuesPrinted: false } });
}
const origin = (await git(["remote", "get-url", "origin"], workspacePath)).stdout.trim();
if (origin !== checkout.repoUrl) {
throw new AgentRunError("infra-failed", "primary workspace origin does not match declared source authority", { httpStatus: 500, details: { declared: gitRemoteSummary(checkout.repoUrl), actual: gitRemoteSummary(origin), valuesPrinted: false } });
}
const probe = path.join(workspacePath, `.agentrun-write-probe-${stableHash({ workspacePath, actualCommit }).slice(0, 12)}`);
try {
await writeFile(probe, "", { flag: "wx" });
await rm(probe, { force: true });
} catch (error) {
await rm(probe, { force: true });
throw new AgentRunError("infra-failed", "primary workspace is not writable", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } });
}
return {
status: "materialized",
sourceContract: "resourceBundleRef",
repoUrl: checkout.repoUrl,
fetchRepoUrl: checkout.fetchRepoUrl,
mirrorUsed: checkout.mirrorUsed,
requestedRef: checkout.requestedRef ?? null,
requestedCommitId: checkout.requestedCommitId ?? null,
sourceCommit: actualCommit,
treeId: checkout.treeId,
branch,
targetPath: workspaceRef.path ?? null,
workspaceRoot: pathSummary(workspacePath),
writable: true,
provenanceHash: stableHash({ repoUrl: checkout.repoUrl, requestedRef: checkout.requestedRef ?? null, requestedCommitId: checkout.requestedCommitId ?? null, sourceCommit: actualCommit, treeId: checkout.treeId, targetPath: workspaceRef.path ?? null }),
valuesPrinted: false,
};
}
function normalizeBranch(value: string): string {
return value.replace(/^refs\/heads\//u, "");
}
export function resolveGitBundleFetchSource(repoUrl: string, gitMirror?: GitMirrorConfig, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } {
const mirror = gitMirror ? { baseUrl: normalizeMirrorBaseUrl(gitMirror.baseUrl) } : defaultGitMirrorConfig(env);
const githubPath = githubRepoPath(repoUrl);
@@ -596,6 +726,7 @@ interface GitCommandOptions {
sourceRef?: GitSourceRef;
sourceContext?: GitBundleSourceContext;
credentialRefPresent?: boolean;
env?: NodeJS.ProcessEnv;
}
export function classifyGitStderr(stderr: string): string {
@@ -611,7 +742,7 @@ export function classifyGitStderr(stderr: string): string {
}
async function git(args: string[], cwd: string, options: GitCommandOptions = {}): Promise<{ stdout: string; stderr: string }> {
const env = gitCommandEnv(process.env);
const env = gitCommandEnv({ ...process.env, ...(options.env ?? {}) });
const child = spawn("git", gitArgs(args), { cwd, stdio: ["ignore", "pipe", "pipe"], env });
let stdout = "";
let stderr = "";
+7 -7
View File
@@ -152,7 +152,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
if (!materializationAttempted) {
materializationAttempted = true;
try {
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId, claimed.workspaceRef));
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, claimed.workspaceRef, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId));
if (materialized) {
workspacePath = materialized.workspacePath;
resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized);
@@ -215,25 +215,25 @@ function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.Pr
};
}
function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string, workspaceRef: RunRecord["workspaceRef"]): NodeJS.ProcessEnv {
const workspaceBranch = typeof workspaceRef.branch === "string" && workspaceRef.branch.trim().length > 0 ? workspaceRef.branch.trim() : undefined;
function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string): NodeJS.ProcessEnv {
return {
...env,
AGENTRUN_RUN_ID: env.AGENTRUN_RUN_ID ?? runId,
AGENTRUN_ATTEMPT_ID: env.AGENTRUN_ATTEMPT_ID ?? attemptId,
...(workspaceBranch ? { AGENTRUN_WORKSPACE_BRANCH: env.AGENTRUN_WORKSPACE_BRANCH ?? workspaceBranch, AGENTRUN_WORKSPACE_REF: env.AGENTRUN_WORKSPACE_REF ?? workspaceBranch } : {}),
};
}
function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited<ReturnType<typeof materializeResourceBundle>>): NodeJS.ProcessEnv | undefined {
if (!materialized) return undefined;
let next: NodeJS.ProcessEnv | undefined;
const sanitized = { ...env };
delete sanitized.AGENTRUN_RESOURCE_CREDENTIAL_PATH;
let next: NodeJS.ProcessEnv | undefined = sanitized;
if (materialized.binPath) {
const withPath = prependPath(env, materialized.binPath);
const withPath = prependPath(sanitized, materialized.binPath);
next = { ...withPath, AGENTRUN_RESOURCE_BIN_PATH: withPath.AGENTRUN_RESOURCE_BIN_PATH ?? materialized.binPath };
}
if (materialized.skillsDir) {
const base = next ?? { ...env };
const base = next ?? sanitized;
const previous = base.AGENTRUN_SKILLS_DIRS;
next = {
...base,
+47 -2
View File
@@ -36,8 +36,9 @@ const selfTest: SelfTestCase = async (context) => {
}];
const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials, ...githubSshToolCredentials];
const item = await createRunWithCommand(client, { ...context, toolCredentials: combinedToolCredentials }, "job smoke", "selftest-job-render", 15_000);
const baseRun = await client.get(`/api/v1/runs/${item.runId}`) as RunRecord;
const rendered = renderRunnerJobDryRun({
run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord,
run: baseRun,
commandId: item.commandId,
managerUrl: server.baseUrl,
runnerApiKeySecretRef,
@@ -65,6 +66,27 @@ const selfTest: SelfTestCase = async (context) => {
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_API_KEY"]);
assertNoSecretLeak(rendered);
const resourceCredentialRendered = renderRunnerJobDryRun({
run: {
...baseRun,
resourceBundleRef: {
kind: "gitbundle",
repoUrl: "git@github.com:pikasTech/unidesk.git",
ref: "master",
bundles: [{ subpath: "tools", targetPath: "tools" }],
credentialRef: { name: "agentrun-v01-resource-github", keys: ["id_ed25519", "known_hosts", "config"] },
},
},
commandId: item.commandId,
managerUrl: server.baseUrl,
runnerApiKeySecretRef,
image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111",
attemptId: "attempt_resource_credential_selftest",
sourceCommit: "self-test",
});
assertRunnerJobUsesResourceCredential(resourceCredentialRendered, "agentrun-v01-resource-github", ["id_ed25519", "known_hosts", "config"]);
assertNoSecretLeak(resourceCredentialRendered);
await assert.rejects(
() => createRunWithCommand(client, {
...context,
@@ -529,7 +551,7 @@ process.exit(1);
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01");
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.agentrun-sessions/sessions");
assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions");
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-bounded-git-transport-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-job-pre-create-retention", "runner-job-pre-create-retention-protects-stale-active", "runner-k8s-job-session-pvc-volume-and-env"] };
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-bounded-git-transport-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-resource-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-job-pre-create-retention", "runner-job-pre-create-retention-protects-stale-active", "runner-k8s-job-session-pvc-volume-and-env"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -680,6 +702,29 @@ function assertRunnerJobUsesToolCredentialVolume(rendered: JsonRecord, secretNam
assert.equal(summaryEntry.valuesPrinted, false);
}
function assertRunnerJobUsesResourceCredential(rendered: JsonRecord, secretName: string, secretKeys: 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 mounts = runner.volumeMounts as JsonRecord[];
const mount = mounts.find((item) => item.mountPath === "/var/run/agentrun/resource-git") as JsonRecord | undefined;
assert.ok(mount);
assert.equal(mount.readOnly, true);
const volumes = podSpec.volumes as JsonRecord[];
const volume = volumes.find((item) => item.name === mount.name) as JsonRecord;
assert.equal(((volume.secret as JsonRecord).secretName), secretName);
assert.deepEqual(((volume.secret as JsonRecord).items as JsonRecord[]).map((item) => item.key), secretKeys);
assert.equal(runnerEnvValue(manifest, "AGENTRUN_RESOURCE_CREDENTIAL_PATH"), "/var/run/agentrun/resource-git");
const summary = rendered.resourceCredential as JsonRecord;
assert.equal(summary.present, true);
assert.equal(summary.name, secretName);
assert.deepEqual(summary.keys, secretKeys);
assert.equal(summary.valuesPrinted, false);
}
function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void {
const spec = manifest.spec as JsonRecord;
const template = spec.template as JsonRecord;
+17 -5
View File
@@ -124,8 +124,13 @@ process.exit(1);
const sessionRun = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello session", "hwlab-command-session");
const resourceBinPath = path.join(context.tmp, "resource-bin");
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces"), AGENTRUN_RESOURCE_BIN_PATH: resourceBinPath }, oneShot: true });
const primaryWorkspacePath = path.join(context.tmp, "primary-workspace");
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces"), AGENTRUN_WORKSPACE_PATH: primaryWorkspacePath, AGENTRUN_RESOURCE_BIN_PATH: resourceBinPath }, oneShot: true });
assert.equal(runResult.terminalStatus, "completed");
await access(path.join(primaryWorkspacePath, ".git"));
await access(path.join(primaryWorkspacePath, "README.md"));
assert.equal((await execFile("git", ["rev-parse", "HEAD"], { cwd: primaryWorkspacePath })).stdout.trim(), bundle.commitId);
assert.equal((await execFile("git", ["remote", "get-url", "origin"], { cwd: primaryWorkspacePath })).stdout.trim(), bundle.repoUrl);
await access(path.join(resourceBinPath, "hwpod"));
const resourceBinExec = await execFile(path.join(resourceBinPath, "hwpod"), ["--selftest"]);
assert.match(resourceBinExec.stdout, /hwpod-selftest/u);
@@ -144,6 +149,12 @@ process.exit(1);
const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
const primaryWorkspace = materialized.primaryWorkspace as JsonRecord;
assert.equal(primaryWorkspace.status, "materialized");
assert.equal(primaryWorkspace.sourceContract, "resourceBundleRef");
assert.equal(primaryWorkspace.sourceCommit, bundle.commitId);
assert.equal(primaryWorkspace.targetPath, ".");
assert.equal(primaryWorkspace.writable, true);
assert.deepEqual(((materialized.tools as JsonRecord).names), ["apply_patch", "hwpod", "tran", "trans"]);
assert.equal(((materialized.tools as JsonRecord).installed), true);
const runtimeToolNames = (((materialized.tools as JsonRecord).runtimeTools as JsonRecord).names) as string[];
@@ -166,10 +177,10 @@ process.exit(1);
assert.equal(refRunResult.terminalStatus, "completed");
const refEnvelope = await client.get(`/api/v1/runs/${refRun.runId}/commands/${refRun.commandId}/result`) as JsonRecord;
const refResource = refEnvelope.resourceBundleRef as JsonRecord;
assert.equal(refResource.commitId, refBundle.commitId, "result summary keeps the request commit as a non-authoritative hint");
assert.equal(refResource.commitId, refBundle.latestCommitId, "result summary keeps the declared immutable source commit");
const refMaterialized = refResource.materialized as JsonRecord;
assert.equal(refMaterialized.commitId, refBundle.latestCommitId, "materialized bundle must resolve the current workspaceRef.branch commit");
assert.equal(refMaterialized.requestedCommitId, refBundle.commitId);
assert.equal(refMaterialized.requestedCommitId, refBundle.latestCommitId);
assert.equal(refMaterialized.requestedRef, refBundle.branch);
const refBundleCommits = (((refMaterialized.bundles as JsonRecord).items as JsonRecord[]).map((item) => item.commitId));
assert.deepEqual(refBundleCommits, [refBundle.latestCommitId, refBundle.latestCommitId]);
@@ -401,13 +412,14 @@ async function createRefResolvedLocalGitBundle(context: SelfTestContext): Promis
}
async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> {
const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId: bundle.commitId, bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false };
const commitId = "latestCommitId" in bundle && typeof bundle.latestCommitId === "string" ? bundle.latestCommitId : bundle.commitId;
const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId, ...(bundle.branch ? { ref: bundle.branch } : {}), bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false };
if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs;
if (bundle.requiredSkills) resourceBundleRef.requiredSkills = bundle.requiredSkills;
const run = await client.post("/api/v1/runs", {
tenantId: "hwlab",
projectId: "pikasTech/HWLAB",
workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB", ...(bundle.branch ? { branch: bundle.branch } : {}) },
workspaceRef: { kind: "opaque", path: ".", ...(bundle.branch ? { branch: bundle.branch } : {}) },
sessionRef: { sessionId, conversationId: sessionId },
resourceBundleRef,
providerId: "G14",
+10 -1
View File
@@ -39,6 +39,11 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(task.backendProfile, "sub2api");
assert.equal(task.providerId, "G14");
assert.equal(task.idempotencyKey, "selftest-aipod-artificer");
const workspaceRef = task.workspaceRef as JsonRecord;
assert.equal(workspaceRef.kind, "opaque");
assert.equal(workspaceRef.path, ".");
assert.equal(workspaceRef.repo, "pikasTech/unidesk");
assert.equal(workspaceRef.branch, "master");
const sessionRef = task.sessionRef as JsonRecord;
assert.match(String(sessionRef.sessionId), /^sess_artificer_[a-f0-9]{24}$/u, "Artificer queue task should default to a resumable session");
assert.equal(sessionRef.conversationId, sessionRef.sessionId, "default Artificer conversation should match the generated session");
@@ -83,6 +88,10 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(classifyGitStderr("fatal: couldn't find remote ref missing-branch\n"), "source-ref-not-found");
assert.equal(classifyGitStderr("fatal: Authentication failed for 'https://example.test/repo.git/'\n"), "authentication-failed");
assert.throws(() => validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", ref: "master", gitMirror: { enabled: false }, bundles: [{ subpath: ".", targetPath: "." }] }), /resourceBundleRef.gitMirror is removed/u);
assert.throws(() => validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", bundles: [{ subpath: "tools", targetPath: "tools" }] }), /must declare ref or a full commitId/u);
assert.throws(() => validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", ref: "master", bundles: [{ repoUrl: "git@github.com:pikasTech/agentrun.git", subpath: "tools", targetPath: "tools" }] }), /different repoUrl and must declare its own ref or commitId/u);
assert.throws(() => validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", ref: "master", bundles: [{ subpath: "tools", targetPath: "tools" }, { subpath: ".agents/skills", targetPath: "tools" }] }), /target_path conflicts/u);
assert.throws(() => validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", ref: "master", bundles: [{ subpath: ".git", targetPath: ".git" }] }), /cannot replace primary workspace Git metadata/u);
const submitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--aipod", "Artificer", "--prompt", "处理 pikasTech/unidesk#245", "--idempotency-key", "selftest-aipod-cli", "--dry-run"]);
assert.equal(submitPlan.ok, true);
@@ -96,7 +105,7 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(commands.some((item) => item.includes("aipod-specs render <name>")), true);
assert.equal(commands.some((item) => item.includes("queue submit --aipod <name>")), true);
assertNoSecretLeak(submitPlan);
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-github-url-render", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "queue-submit-aipod-dry-run", "aipod-cli-help"] };
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "aipod-cli-help"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
+154
View File
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { AgentRunError } from "../../common/errors.js";
import type { JsonRecord, ResourceBundleRef, WorkspaceRef } from "../../common/types.js";
import { ManagerClient } from "../../mgr/client.js";
import { startManagerServer } from "../../mgr/server.js";
import { MemoryAgentRunStore } from "../../mgr/store.js";
import { materializeResourceBundle } from "../../runner/resource-bundle.js";
import { runOnce } from "../../runner/run-once.js";
import type { SelfTestCase, SelfTestContext } from "../harness.js";
const execFile = promisify(execFileCallback);
const selfTest: SelfTestCase = async (context) => {
const primary = await createRepo(context, "primary-source", {
"AGENTS.md": "# Primary workspace\n",
"package.json": "{\"name\":\"primary-workspace-selftest\"}\n",
});
const addon = await createRepo(context, "addon-source", {
"capability/probe": "#!/usr/bin/env sh\necho capability\n",
});
const workspaceRef: WorkspaceRef = { kind: "opaque", path: ".", branch: primary.branch };
const resourceBundleRef: ResourceBundleRef = {
kind: "gitbundle",
repoUrl: primary.path,
ref: primary.branch,
commitId: primary.commitId,
bundles: [{ name: "addon", repoUrl: addon.path, commitId: addon.commitId, subpath: "capability", targetPath: "tools" }],
};
const workspacePath = path.join(context.tmp, "primary-workspace-materialized");
const env = { AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "primary-workspace-assembly"), AGENTRUN_WORKSPACE_PATH: workspacePath, AGENTRUN_RUN_ID: "run_primary_workspace_selftest" };
const first = await materializeResourceBundle(resourceBundleRef, workspaceRef, env);
assert.ok(first);
await access(path.join(workspacePath, ".git"));
await access(path.join(workspacePath, "AGENTS.md"));
await access(path.join(workspacePath, "package.json"));
await access(path.join(workspacePath, "tools", "probe"));
assert.equal((await execFile("git", ["rev-parse", "HEAD"], { cwd: workspacePath })).stdout.trim(), primary.commitId);
assert.equal((await execFile("git", ["branch", "--show-current"], { cwd: workspacePath })).stdout.trim(), primary.branch);
assert.equal((await execFile("git", ["remote", "get-url", "origin"], { cwd: workspacePath })).stdout.trim(), primary.path);
const firstPrimary = first.event.primaryWorkspace as JsonRecord;
assert.equal(firstPrimary.sourceCommit, primary.commitId);
assert.equal(firstPrimary.sourceContract, "resourceBundleRef");
assert.equal(firstPrimary.writable, true);
await writeFile(path.join(workspacePath, "stale-from-previous-attempt.txt"), "stale\n", "utf8");
const replay = await materializeResourceBundle(resourceBundleRef, workspaceRef, env);
assert.ok(replay);
await assert.rejects(access(path.join(workspacePath, "stale-from-previous-attempt.txt")));
assert.equal((replay.event.primaryWorkspace as JsonRecord).provenanceHash, firstPrimary.provenanceHash);
await assert.rejects(
() => materializeResourceBundle({ ...resourceBundleRef, credentialRef: { name: "missing-resource-git", keys: ["id_ed25519", "known_hosts"] } }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_missing_resource_credential", AGENTRUN_RESOURCE_CREDENTIAL_PATH: undefined }),
(error) => error instanceof AgentRunError && error.failureKind === "secret-unavailable" && /runtime projection is missing/u.test(error.message),
);
const missingRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: primary.path, ref: "missing-primary-ref", bundles: resourceBundleRef.bundles };
await assert.rejects(
() => materializeResourceBundle(missingRef, { ...workspaceRef, branch: "missing-primary-ref" }, { ...env, AGENTRUN_RUN_ID: "run_missing_primary_ref" }),
(error) => {
if (!(error instanceof AgentRunError) || error.failureKind !== "infra-failed") return false;
const source = error.details?.source as JsonRecord | undefined;
const bundle = source?.bundle as JsonRecord | undefined;
const stderr = error.details?.stderr as JsonRecord | undefined;
return bundle?.kind === "resource-bundle" && stderr?.category === "source-ref-not-found";
},
);
const store = new MemoryAgentRunStore();
const server = await startManagerServer({
port: 0,
host: "127.0.0.1",
sourceCommit: "self-test",
store,
runnerJobDefaults: {
namespace: "agentrun-selftest",
managerUrl: "http://agentrun-mgr.agentrun-selftest.svc.cluster.local:8080",
runnerApiKeySecretRef: { name: "agentrun-selftest-api-key", key: "HWLAB_API_KEY" },
image: "selftest.invalid/agentrun@sha256:1111111111111111111111111111111111111111111111111111111111111111",
serviceAccountName: "agentrun-selftest-runner",
jobNamePrefix: "agentrun-selftest-runner",
lane: "selftest",
},
});
try {
const client = new ManagerClient(server.baseUrl);
const run = await client.post("/api/v1/runs", {
tenantId: "unidesk",
projectId: "pikasTech/unidesk",
workspaceRef: { kind: "opaque", path: ".", branch: "missing-primary-ref" },
sessionRef: null,
resourceBundleRef: missingRef,
providerId: "NC01",
backendProfile: "codex",
executionPolicy: {
sandbox: "workspace-write",
approval: "never",
timeoutMs: 10_000,
network: "default",
secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile: "codex", secretRef: { name: "agentrun-selftest-provider-codex", keys: ["auth.json", "config.toml"], mountPath: context.codexHome } }] },
},
traceSink: null,
}) as { id: string };
const command = await client.post(`/api/v1/runs/${run.id}/commands`, { type: "turn", payload: { prompt: "backend must not start" }, idempotencyKey: "primary-workspace-pre-turn-failure" }) as { id: string };
const result = await runOnce({ managerUrl: server.baseUrl, runId: run.id, commandId: command.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "primary-workspace-runner-failure") }, oneShot: true }) as JsonRecord;
assert.equal(result.terminalStatus, "failed");
assert.equal(result.failureKind, "infra-failed");
const commandResult = await client.get(`/api/v1/runs/${run.id}/commands/${command.id}/result`) as JsonRecord;
assert.equal(commandResult.terminalStatus, "failed");
assert.equal(commandResult.completed, false);
assert.equal(commandResult.failureKind, "infra-failed");
const events = await client.get(`/api/v1/runs/${run.id}/events?afterSeq=0&limit=100`) as { items?: Array<{ type?: string; payload?: JsonRecord }> };
assert.equal((events.items ?? []).some((event) => event.payload?.phase === "resource-bundle-materialized"), false);
assert.equal((events.items ?? []).some((event) => event.payload?.phase === "backend-turn-started"), false);
assert.equal((events.items ?? []).some((event) => event.type === "terminal_status" && event.payload?.failureKind === "infra-failed"), true);
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
return {
name: "primary-workspace",
tests: [
"primary-workspace-exact-source-materialization",
"primary-workspace-provenance-and-declared-origin",
"primary-workspace-retry-idempotent-replacement",
"primary-workspace-credential-missing-typed-blocker",
"primary-workspace-ref-not-found-first-error",
"partial-bundle-cannot-mask-primary-failure",
"primary-workspace-failure-before-backend-turn",
],
};
};
async function createRepo(context: SelfTestContext, name: string, files: Record<string, string>): Promise<{ path: string; branch: string; commitId: string }> {
const repo = path.join(context.tmp, name);
await mkdir(repo, { recursive: true });
await execFile("git", ["init"], { cwd: repo });
for (const [relativePath, text] of Object.entries(files)) {
const file = path.join(repo, relativePath);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, text, "utf8");
}
await execFile("git", ["add", "."], { cwd: repo });
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", name], { cwd: repo });
const branch = (await execFile("git", ["branch", "--show-current"], { cwd: repo })).stdout.trim() || "master";
const commitId = (await execFile("git", ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim();
return { path: repo, branch, commitId };
}
export default selfTest;