diff --git a/config/aipods/artificer.yaml b/config/aipods/artificer.yaml index 59ee5bd..c6fe75a 100644 --- a/config/aipods/artificer.yaml +++ b/config/aipods/artificer.yaml @@ -26,6 +26,8 @@ spec: workspaceRef: kind: opaque path: . + repo: pikasTech/unidesk + branch: master executionPolicy: sandbox: workspace-write approval: never diff --git a/src/common/aipod-specs.ts b/src/common/aipod-specs.ts index fed9019..f676d44 100644 --- a/src/common/aipod-specs.ts +++ b/src/common/aipod-specs.ts @@ -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 }; diff --git a/src/common/validation.ts b/src/common/validation.ts index 6304a52..76b02b4 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -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(); + const seenTargets = new Set(); 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"), diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index 1c99c34..04c230c 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -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 SecretRef;runner 将按 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, diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 39dc131..44357e4 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -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 { +export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, workspaceRef: WorkspaceRef, env: NodeJS.ProcessEnv = process.env): Promise { 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>(); 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 { @@ -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 { + 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 { + 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 { + 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 = ""; diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index 6fa4f5d..5059605 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -152,7 +152,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise { 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>): 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, diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 79a3a58..d79e96b 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -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((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; diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index 94b7b3e..bec2eed 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -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", diff --git a/src/selftest/cases/76-aipod-spec.ts b/src/selftest/cases/76-aipod-spec.ts index 9e5c7c9..3d41886 100644 --- a/src/selftest/cases/76-aipod-spec.ts +++ b/src/selftest/cases/76-aipod-spec.ts @@ -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 ")), true); assert.equal(commands.some((item) => item.includes("queue submit --aipod ")), 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((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/79-primary-workspace.ts b/src/selftest/cases/79-primary-workspace.ts new file mode 100644 index 0000000..815a661 --- /dev/null +++ b/src/selftest/cases/79-primary-workspace.ts @@ -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((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): 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;