From b0233256d5a9f426ffabd6c1576b27fdaebcd1af Mon Sep 17 00:00:00 2001 From: root Date: Sun, 12 Jul 2026 06:30:41 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20Artificer=20session?= =?UTF-8?q?=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E7=BB=AD=E8=B7=91=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/aipods/artificer.yaml | 2 - src/common/validation.ts | 32 +- src/runner/resource-bundle.ts | 428 +++++++++++++++--- .../cases/50-hwlab-manual-dispatch.ts | 4 +- .../cases/60-hwlab-baseline-contract.ts | 2 +- src/selftest/cases/76-aipod-spec.ts | 12 +- src/selftest/cases/79-primary-workspace.ts | 112 ++++- 7 files changed, 488 insertions(+), 104 deletions(-) diff --git a/config/aipods/artificer.yaml b/config/aipods/artificer.yaml index c6fe75a..59ee5bd 100644 --- a/config/aipods/artificer.yaml +++ b/config/aipods/artificer.yaml @@ -26,8 +26,6 @@ spec: workspaceRef: kind: opaque path: . - repo: pikasTech/unidesk - branch: master executionPolicy: sandbox: workspace-write approval: never diff --git a/src/common/validation.ts b/src/common/validation.ts index 76b02b4..8ea0075 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -160,36 +160,10 @@ export function validatePrimaryWorkspaceContract(workspaceRef: WorkspaceRef | nu 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 rejectedKeys = Object.keys(workspaceRef).filter((key) => key !== "kind" && key !== "path"); + if (rejectedKeys.length > 0) { + throw new AgentRunError("schema-invalid", "gitbundle primary workspaceRef only supports kind and path; resourceBundleRef is the source authority", { httpStatus: 400, details: { rejectedKeys: rejectedKeys.sort(), 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 { diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 44357e4..30896d5 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -81,7 +81,7 @@ interface GitSourceRef { value: string; } -interface MaterializedGitBundle { +interface MaterializedGitBundle extends JsonRecord { name: string | null; repoUrl: string; fetchRepoUrl: string; @@ -96,6 +96,38 @@ interface MaterializedGitBundle { sourceBytes: number | null; } +interface PrimarySourceContract extends JsonRecord { + repoUrl: string; + requestedRef: string | null; + requestedCommitId: string | null; +} + +interface ResolvedPrimarySource extends JsonRecord { + commitId: string; + treeId: string; + branch: string | null; + fetchRepoUrl: string; + mirrorUsed: boolean; + mirrorBaseUrl: string | null; +} + +interface PrimaryWorkspaceProvenance extends JsonRecord { + schemaVersion: 1; + declaredSource: PrimarySourceContract; + resolvedSource: ResolvedPrimarySource; + resolutionAuthority: "runner-first-materialization" | "declared-commit"; + assemblyContractHash: string; + bundleContractHash: string; + bundleProvenance: MaterializedGitBundle[]; + provenanceHash: string; +} + +type PrimaryWorkspaceState = + | { kind: "uninitialized" } + | { kind: "continuation"; provenance: PrimaryWorkspaceProvenance }; + +const primaryWorkspaceProvenanceFile = "agentrun-primary-workspace.json"; + export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, workspaceRef: WorkspaceRef, env: NodeJS.ProcessEnv = process.env): Promise { if (!resourceBundleRef) return null; assertPrimaryWorkspaceRef(workspaceRef, resourceBundleRef); @@ -108,30 +140,64 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl if (explicitWorkspacePath && isSameOrChildPath(workspacePath, assemblyRoot)) { throw new AgentRunError("schema-invalid", "AGENTRUN_WORKSPACE_PATH must not be inside transient resource assembly root", { httpStatus: 400, details: { workspacePath: pathSummary(workspacePath), assemblyRoot: pathSummary(assemblyRoot), valuesPrinted: false } }); } - await rm(assemblyRoot, { recursive: true, force: true }); - await mkdir(checkoutRoot, { recursive: true }); + const declaredSource = primarySourceContract(resourceBundleRef); + const assemblyContractHash = primaryAssemblyContractHash(resourceBundleRef, workspaceRef); + const bundleContractHash = stableHash(resourceBundleRef.bundles); + const initialState = await inspectPrimaryWorkspace(workspacePath, declaredSource, assemblyContractHash, bundleContractHash); const gitMirror = gitMirrorConfig(resourceBundleRef, env); - 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)); - let checkout = checkoutCache.get(key); - if (!checkout) { - checkout = checkoutGitSource(checkoutRoot, source, sourceContext); - checkoutCache.set(key, checkout); + let provenance: PrimaryWorkspaceProvenance; + let materializedBundles: MaterializedGitBundle[]; + let checkoutPath: string | null = null; + let workspaceLifecycle: "first-materialization" | "continuation-reuse"; + + if (initialState.kind === "continuation") { + provenance = initialState.provenance; + materializedBundles = provenance.bundleProvenance; + workspaceLifecycle = "continuation-reuse"; + } else { + await rm(checkoutRoot, { recursive: true, force: true }); + await mkdir(checkoutRoot, { recursive: true }); + 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)); + let checkout = checkoutCache.get(key); + if (!checkout) { + checkout = checkoutGitSource(checkoutRoot, source, sourceContext); + checkoutCache.set(key, checkout); + } + return checkout; + }; + const defaultCheckout = await checkoutFor(defaultSource, { kind: "resource-bundle", index: null, name: null }); + checkoutPath = defaultCheckout.checkoutPath; + const stagePath = await stagePrimaryWorkspace(defaultCheckout.checkoutPath, workspacePath, runScope); + try { + const stagedBundles = await materializeGitBundles(stagePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor); + const stagedProvenance = createPrimaryWorkspaceProvenance(resourceBundleRef, workspaceRef, defaultCheckout, stagedBundles); + await verifyPrimaryWorkspace(stagePath, workspaceRef, stagedProvenance, "first-materialization", true); + await writePrimaryWorkspaceProvenance(stagePath, stagedProvenance); + const installed = await installPrimaryWorkspace(stagePath, workspacePath); + const installedState = await inspectPrimaryWorkspace(workspacePath, declaredSource, assemblyContractHash, bundleContractHash); + if (installedState.kind !== "continuation") { + throw new AgentRunError("infra-failed", "primary workspace installation did not produce durable provenance", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), valuesPrinted: false } }); + } + provenance = installedState.provenance; + materializedBundles = provenance.bundleProvenance; + workspaceLifecycle = installed ? "first-materialization" : "continuation-reuse"; + if (!installed) checkoutPath = null; + } finally { + await rm(stagePath, { recursive: true, force: true }); } - 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 primaryWorkspace = await verifyPrimaryWorkspace(workspacePath, workspaceRef, provenance, workspaceLifecycle, false); const tools = await prepareGitBundleTools(workspacePath, env); const skills = await discoverGitBundleSkills(workspacePath, materializedBundles); const requiredSkills = materializeRequiredSkills(resourceBundleRef.requiredSkills ?? [], skills.items); - const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []); + const prompts = await materializePromptRefs(checkoutPath ?? workspacePath, resourceBundleRef.promptRefs ?? []); const initialPrompt = assembleInitialPrompt(prompts.items, skills.items); + const resolvedSource = provenance.resolvedSource; return { workspacePath, ...(tools.binPath ? { binPath: tools.binPath } : {}), @@ -141,17 +207,19 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl phase: "resource-bundle-materialized", kind: "gitbundle", repoUrl: resourceBundleRef.repoUrl, - fetchRepoUrl: defaultCheckout.fetchRepoUrl, - mirrorUsed: defaultCheckout.mirrorUsed, - mirrorBaseUrl: defaultCheckout.mirrorBaseUrl ?? null, - gitMirror: gitMirror ? { enabled: true, baseUrl: gitMirror.baseUrl, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false }, - commitId: defaultCheckout.commitId, + fetchRepoUrl: resolvedSource.fetchRepoUrl, + mirrorUsed: resolvedSource.mirrorUsed, + mirrorBaseUrl: resolvedSource.mirrorBaseUrl, + gitMirror: resolvedSource.mirrorUsed ? { enabled: true, baseUrl: resolvedSource.mirrorBaseUrl, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false }, + commitId: resolvedSource.commitId, requestedCommitId: resourceBundleRef.commitId ?? null, - requestedRef: defaultCheckout.requestedRef ?? null, - treeId: defaultCheckout.treeId, - checkoutPath: pathSummary(defaultCheckout.checkoutPath), + requestedRef: resourceBundleRef.ref ?? null, + treeId: resolvedSource.treeId, + checkoutPath: checkoutPath ? pathSummary(checkoutPath) : null, workspacePath: pathSummary(workspacePath), workspacePersistence: explicitWorkspacePath ? { mode: "session", path: pathSummary(workspacePath), valuesPrinted: false } : { mode: "run", path: pathSummary(workspacePath), valuesPrinted: false }, + workspaceLifecycle, + resolutionAuthority: provenance.resolutionAuthority, primaryWorkspace, gitTransport: gitTransportSummary(), bundles: { @@ -287,32 +355,260 @@ function localBranchFromRef(ref: string): string | null { } function assertPrimaryWorkspaceRef(workspaceRef: WorkspaceRef, resourceBundleRef: ResourceBundleRef): void { + void resourceBundleRef; 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 } }); + const rejectedKeys = Object.keys(workspaceRef).filter((key) => key !== "kind" && key !== "path"); + if (rejectedKeys.length > 0) { + throw new AgentRunError("schema-invalid", "gitbundle primary workspaceRef only supports kind and path; resourceBundleRef is the source authority", { httpStatus: 400, details: { rejectedKeys: rejectedKeys.sort(), valuesPrinted: false } }); } } -async function replacePrimaryWorkspace(checkoutPath: string, workspacePath: string, runScope: string): Promise { +function primarySourceContract(resourceBundleRef: ResourceBundleRef): PrimarySourceContract { + return { + repoUrl: resourceBundleRef.repoUrl, + requestedRef: resourceBundleRef.ref ?? null, + requestedCommitId: resourceBundleRef.commitId ?? null, + }; +} + +function primaryAssemblyContractHash(resourceBundleRef: ResourceBundleRef, workspaceRef: WorkspaceRef): string { + return stableHash({ + schemaVersion: 1, + workspaceRef: { kind: workspaceRef.kind, path: workspaceRef.path ?? null }, + bundles: resourceBundleRef.bundles, + promptRefs: resourceBundleRef.promptRefs ?? [], + requiredSkills: resourceBundleRef.requiredSkills ?? [], + submodules: resourceBundleRef.submodules ?? false, + lfs: resourceBundleRef.lfs ?? false, + }); +} + +function createPrimaryWorkspaceProvenance(resourceBundleRef: ResourceBundleRef, workspaceRef: WorkspaceRef, checkout: GitCheckout, bundles: MaterializedGitBundle[]): PrimaryWorkspaceProvenance { + const body: JsonRecord = { + schemaVersion: 1, + declaredSource: primarySourceContract(resourceBundleRef), + resolvedSource: { + commitId: checkout.commitId, + treeId: checkout.treeId, + branch: checkout.branch, + fetchRepoUrl: checkout.fetchRepoUrl, + mirrorUsed: checkout.mirrorUsed, + mirrorBaseUrl: checkout.mirrorBaseUrl ?? null, + }, + resolutionAuthority: resourceBundleRef.commitId ? "declared-commit" : "runner-first-materialization", + assemblyContractHash: primaryAssemblyContractHash(resourceBundleRef, workspaceRef), + bundleContractHash: stableHash(resourceBundleRef.bundles), + bundleProvenance: bundles, + }; + return { ...body, provenanceHash: stableHash(body) } as PrimaryWorkspaceProvenance; +} + +async function inspectPrimaryWorkspace(workspacePath: string, declaredSource: PrimarySourceContract, assemblyContractHash: string, bundleContractHash: string): Promise { + let root; + try { + root = await stat(workspacePath); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return { kind: "uninitialized" }; + throw new AgentRunError("infra-failed", "primary workspace cannot be inspected", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } }); + } + if (!root.isDirectory()) { + throw new AgentRunError("schema-invalid", "primary workspace path exists but is not a directory", { httpStatus: 400, details: { workspacePath: pathSummary(workspacePath), valuesPrinted: false } }); + } + let entries; + try { + entries = await readdir(workspacePath); + } catch (error) { + throw new AgentRunError("infra-failed", "primary workspace directory cannot be read", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } }); + } + if (entries.length === 0) return { kind: "uninitialized" }; + const provenance = await readPrimaryWorkspaceProvenance(workspacePath); + assertPrimaryWorkspaceProvenanceContract(provenance, declaredSource, assemblyContractHash, bundleContractHash); + return { kind: "continuation", provenance }; +} + +async function readPrimaryWorkspaceProvenance(workspacePath: string): Promise { + const file = path.join(workspacePath, ".git", primaryWorkspaceProvenanceFile); + let text: string; + try { + text = await readFile(file, "utf8"); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + throw new AgentRunError("schema-invalid", "non-empty primary workspace is missing AgentRun provenance", { httpStatus: 400, details: { workspacePath: pathSummary(workspacePath), provenancePath: pathSummary(file), valuesPrinted: false } }); + } + throw new AgentRunError("infra-failed", "primary workspace provenance cannot be read", { httpStatus: 500, details: { provenancePath: pathSummary(file), error: fileErrorSummary(error), valuesPrinted: false } }); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new AgentRunError("schema-invalid", "primary workspace provenance is not valid JSON", { httpStatus: 400, details: { provenancePath: pathSummary(file), error: fileErrorSummary(error), valuesPrinted: false } }); + } + if (!isJsonObject(parsed)) { + throw new AgentRunError("schema-invalid", "primary workspace provenance must be an object", { httpStatus: 400, details: { provenancePath: pathSummary(file), valuesPrinted: false } }); + } + const provenanceHash = requiredProvenanceString(parsed, "provenanceHash"); + const hashBody = { ...parsed }; + delete hashBody.provenanceHash; + if (stableHash(hashBody) !== provenanceHash) { + throw new AgentRunError("schema-invalid", "primary workspace provenance hash does not match its content", { httpStatus: 400, details: { provenancePath: pathSummary(file), valuesPrinted: false } }); + } + if (parsed.schemaVersion !== 1) { + throw new AgentRunError("schema-invalid", "primary workspace provenance schemaVersion is unsupported", { httpStatus: 400, details: { schemaVersion: parsed.schemaVersion ?? null, valuesPrinted: false } }); + } + const declaredSourceRecord = requiredProvenanceObject(parsed, "declaredSource"); + const resolvedSourceRecord = requiredProvenanceObject(parsed, "resolvedSource"); + const resolutionAuthority = parsed.resolutionAuthority; + if (resolutionAuthority !== "runner-first-materialization" && resolutionAuthority !== "declared-commit") { + throw new AgentRunError("schema-invalid", "primary workspace provenance resolutionAuthority is invalid", { httpStatus: 400 }); + } + if (!Array.isArray(parsed.bundleProvenance)) { + throw new AgentRunError("schema-invalid", "primary workspace bundle provenance must be an array", { httpStatus: 400 }); + } + const provenance: PrimaryWorkspaceProvenance = { + schemaVersion: 1, + declaredSource: { + repoUrl: requiredProvenanceString(declaredSourceRecord, "repoUrl"), + requestedRef: nullableProvenanceString(declaredSourceRecord, "requestedRef"), + requestedCommitId: nullableProvenanceString(declaredSourceRecord, "requestedCommitId"), + }, + resolvedSource: { + commitId: requiredProvenanceString(resolvedSourceRecord, "commitId"), + treeId: requiredProvenanceString(resolvedSourceRecord, "treeId"), + branch: nullableProvenanceString(resolvedSourceRecord, "branch"), + fetchRepoUrl: requiredProvenanceString(resolvedSourceRecord, "fetchRepoUrl"), + mirrorUsed: requiredProvenanceBoolean(resolvedSourceRecord, "mirrorUsed"), + mirrorBaseUrl: nullableProvenanceString(resolvedSourceRecord, "mirrorBaseUrl"), + }, + resolutionAuthority, + assemblyContractHash: requiredProvenanceString(parsed, "assemblyContractHash"), + bundleContractHash: requiredProvenanceString(parsed, "bundleContractHash"), + bundleProvenance: parsed.bundleProvenance.map((item, index) => parseMaterializedBundleProvenance(item, index)), + provenanceHash, + }; + return provenance; +} + +function assertPrimaryWorkspaceProvenanceContract(provenance: PrimaryWorkspaceProvenance, declaredSource: PrimarySourceContract, assemblyContractHash: string, bundleContractHash: string): void { + if (stableHash(provenance.declaredSource) !== stableHash(declaredSource)) { + throw new AgentRunError("schema-invalid", "primary workspace declared source conflicts with frozen provenance", { httpStatus: 400, details: { expectedSourceHash: stableHash(declaredSource), actualSourceHash: stableHash(provenance.declaredSource), valuesPrinted: false } }); + } + if (provenance.assemblyContractHash !== assemblyContractHash || provenance.bundleContractHash !== bundleContractHash) { + throw new AgentRunError("schema-invalid", "primary workspace assembly contract conflicts with frozen provenance", { httpStatus: 400, details: { expectedAssemblyContractHash: assemblyContractHash, actualAssemblyContractHash: provenance.assemblyContractHash, expectedBundleContractHash: bundleContractHash, actualBundleContractHash: provenance.bundleContractHash, valuesPrinted: false } }); + } + const expectedAuthority = declaredSource.requestedCommitId ? "declared-commit" : "runner-first-materialization"; + if (provenance.resolutionAuthority !== expectedAuthority) { + throw new AgentRunError("schema-invalid", "primary workspace resolution authority conflicts with declared source", { httpStatus: 400, details: { expectedAuthority, actualAuthority: provenance.resolutionAuthority, valuesPrinted: false } }); + } + if (declaredSource.requestedCommitId && provenance.resolvedSource.commitId !== declaredSource.requestedCommitId) { + throw new AgentRunError("schema-invalid", "primary workspace frozen commit conflicts with declared commit", { httpStatus: 400, details: { declaredCommit: declaredSource.requestedCommitId, frozenCommit: provenance.resolvedSource.commitId, valuesPrinted: false } }); + } +} + +function parseMaterializedBundleProvenance(value: unknown, index: number): MaterializedGitBundle { + if (!isJsonObject(value)) throw new AgentRunError("schema-invalid", `primary workspace bundle provenance[${index}] must be an object`, { httpStatus: 400 }); + const sourceKind = value.sourceKind; + if (sourceKind !== "file" && sourceKind !== "directory") throw new AgentRunError("schema-invalid", `primary workspace bundle provenance[${index}].sourceKind is invalid`, { httpStatus: 400 }); + const sourceBytes = value.sourceBytes; + if (sourceBytes !== null && (typeof sourceBytes !== "number" || !Number.isFinite(sourceBytes) || sourceBytes < 0)) { + throw new AgentRunError("schema-invalid", `primary workspace bundle provenance[${index}].sourceBytes is invalid`, { httpStatus: 400 }); + } + return { + name: nullableProvenanceString(value, "name"), + repoUrl: requiredProvenanceString(value, "repoUrl"), + fetchRepoUrl: requiredProvenanceString(value, "fetchRepoUrl"), + mirrorUsed: requiredProvenanceBoolean(value, "mirrorUsed"), + mirrorBaseUrl: nullableProvenanceString(value, "mirrorBaseUrl"), + commitId: requiredProvenanceString(value, "commitId"), + requestedCommitId: nullableProvenanceString(value, "requestedCommitId"), + requestedRef: nullableProvenanceString(value, "requestedRef"), + subpath: requiredProvenanceString(value, "subpath"), + targetPath: requiredProvenanceString(value, "targetPath"), + sourceKind, + sourceBytes, + }; +} + +function isJsonObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requiredProvenanceObject(record: JsonRecord, key: string): JsonRecord { + const value = record[key]; + if (!isJsonObject(value)) throw new AgentRunError("schema-invalid", `primary workspace provenance ${key} must be an object`, { httpStatus: 400 }); + return value; +} + +function requiredProvenanceString(record: JsonRecord, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `primary workspace provenance ${key} must be a non-empty string`, { httpStatus: 400 }); + return value; +} + +function nullableProvenanceString(record: JsonRecord, key: string): string | null { + const value = record[key]; + if (value === null) return null; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `primary workspace provenance ${key} must be a string or null`, { httpStatus: 400 }); + return value; +} + +function requiredProvenanceBoolean(record: JsonRecord, key: string): boolean { + const value = record[key]; + if (typeof value !== "boolean") throw new AgentRunError("schema-invalid", `primary workspace provenance ${key} must be boolean`, { httpStatus: 400 }); + return value; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code; +} + +async function stagePrimaryWorkspace(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; + if (path.resolve(checkoutPath) === path.resolve(workspacePath)) { + throw new AgentRunError("schema-invalid", "primary checkout cannot be the durable workspace path", { httpStatus: 400 }); + } 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); + return stage; } 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 } }); + throw new AgentRunError("infra-failed", "primary workspace staging 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 { +async function writePrimaryWorkspaceProvenance(workspacePath: string, provenance: PrimaryWorkspaceProvenance): Promise { + const file = path.join(workspacePath, ".git", primaryWorkspaceProvenanceFile); + try { + await writeFile(file, `${JSON.stringify(provenance, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + } catch (error) { + throw new AgentRunError("infra-failed", "primary workspace provenance could not be persisted", { httpStatus: 500, details: { provenancePath: pathSummary(file), error: fileErrorSummary(error), valuesPrinted: false } }); + } +} + +async function installPrimaryWorkspace(stagePath: string, workspacePath: string): Promise { + let targetIsInstallable = false; + try { + const root = await stat(workspacePath); + targetIsInstallable = root.isDirectory() && (await readdir(workspacePath)).length === 0; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) targetIsInstallable = true; + else throw new AgentRunError("infra-failed", "primary workspace install target cannot be inspected", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } }); + } + if (!targetIsInstallable) return false; + try { + await rename(stagePath, workspacePath); + return true; + } catch (error) { + if (hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY")) return false; + throw new AgentRunError("infra-failed", "primary workspace atomic installation failed", { httpStatus: 500, details: { stage: pathSummary(stagePath), workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } }); + } +} + +async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: WorkspaceRef, provenance: PrimaryWorkspaceProvenance, workspaceLifecycle: "first-materialization" | "continuation-reuse", requireExactSource: boolean): Promise { let rootStat; let gitStat; try { @@ -325,16 +621,27 @@ async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: Works 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 frozenCommit = provenance.resolvedSource.commitId; + if (requireExactSource && actualCommit !== frozenCommit) { + throw new AgentRunError("infra-failed", "staged primary workspace HEAD does not match resolved source commit", { httpStatus: 500, details: { frozenCommit, actualCommit, valuesPrinted: false } }); + } + if (!requireExactSource) { + const ancestry = await git(["merge-base", "--is-ancestor", frozenCommit, actualCommit], workspacePath, { allowFailure: true }); + if (ancestry.exitCode === 1) { + throw new AgentRunError("schema-invalid", "primary workspace HEAD is not a successor of its frozen source commit", { httpStatus: 400, details: { frozenCommit, actualCommit, valuesPrinted: false } }); + } + if (ancestry.exitCode !== 0) { + throw new AgentRunError("infra-failed", "primary workspace ancestry could not be verified", { httpStatus: 500, details: { frozenCommit, actualCommit, exitCode: ancestry.exitCode, valuesPrinted: false } }); + } + } + const frozenTreeId = (await git(["rev-parse", `${frozenCommit}^{tree}`], workspacePath)).stdout.trim(); + if (frozenTreeId !== provenance.resolvedSource.treeId) { + throw new AgentRunError("schema-invalid", "primary workspace frozen source tree conflicts with provenance", { httpStatus: 400, details: { frozenCommit, expectedTreeId: provenance.resolvedSource.treeId, actualTreeId: frozenTreeId, 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 } }); + if (origin !== provenance.declaredSource.repoUrl) { + throw new AgentRunError("schema-invalid", "primary workspace origin conflicts with declared source authority", { httpStatus: 400, details: { declared: gitRemoteSummary(provenance.declaredSource.repoUrl), actual: gitRemoteSummary(origin), valuesPrinted: false } }); } const probe = path.join(workspacePath, `.agentrun-write-probe-${stableHash({ workspacePath, actualCommit }).slice(0, 12)}`); try { @@ -344,29 +651,36 @@ async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: Works 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 } }); } + const headTreeId = (await git(["rev-parse", "HEAD^{tree}"], workspacePath)).stdout.trim(); 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, + repoUrl: provenance.declaredSource.repoUrl, + fetchRepoUrl: provenance.resolvedSource.fetchRepoUrl, + mirrorUsed: provenance.resolvedSource.mirrorUsed, + requestedRef: provenance.declaredSource.requestedRef, + requestedCommitId: provenance.declaredSource.requestedCommitId, + sourceCommit: frozenCommit, + headCommit: actualCommit, + headIsFrozenSource: actualCommit === frozenCommit, + treeId: provenance.resolvedSource.treeId, + headTreeId, + sourceBranch: provenance.resolvedSource.branch, branch, targetPath: workspaceRef.path ?? null, workspaceRoot: pathSummary(workspacePath), + workspaceLifecycle, + resolutionAuthority: provenance.resolutionAuthority, + assemblyContractHash: provenance.assemblyContractHash, + bundleContractHash: provenance.bundleContractHash, + bundleProvenanceHash: stableHash(provenance.bundleProvenance), + provenancePath: pathSummary(path.join(workspacePath, ".git", primaryWorkspaceProvenanceFile)), writable: true, - provenanceHash: stableHash({ repoUrl: checkout.repoUrl, requestedRef: checkout.requestedRef ?? null, requestedCommitId: checkout.requestedCommitId ?? null, sourceCommit: actualCommit, treeId: checkout.treeId, targetPath: workspaceRef.path ?? null }), + provenanceHash: provenance.provenanceHash, 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); @@ -741,7 +1055,7 @@ export function classifyGitStderr(stderr: string): string { return text.length === 0 ? "stderr-empty" : "unclassified"; } -async function git(args: string[], cwd: string, options: GitCommandOptions = {}): Promise<{ stdout: string; stderr: string }> { +async function git(args: string[], cwd: string, options: GitCommandOptions = {}): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { const env = gitCommandEnv({ ...process.env, ...(options.env ?? {}) }); const child = spawn("git", gitArgs(args), { cwd, stdio: ["ignore", "pipe", "pipe"], env }); let stdout = ""; @@ -816,7 +1130,7 @@ async function git(args: string[], cwd: string, options: GitCommandOptions = {}) }, }); } - return { stdout, stderr }; + return { stdout, stderr, exitCode: result.code }; } function gitRemoteSummary(value: string | undefined): JsonRecord | null { diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index bec2eed..2e6a02b 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -179,7 +179,7 @@ process.exit(1); const refResource = refEnvelope.resourceBundleRef as JsonRecord; 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.commitId, refBundle.latestCommitId, "first materialization must resolve the declared resource ref"); 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)); @@ -419,7 +419,7 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b const run = await client.post("/api/v1/runs", { tenantId: "hwlab", projectId: "pikasTech/HWLAB", - workspaceRef: { kind: "opaque", path: ".", ...(bundle.branch ? { branch: bundle.branch } : {}) }, + workspaceRef: { kind: "opaque", path: "." }, sessionRef: { sessionId, conversationId: sessionId }, resourceBundleRef, providerId: "G14", diff --git a/src/selftest/cases/60-hwlab-baseline-contract.ts b/src/selftest/cases/60-hwlab-baseline-contract.ts index 944a254..927cd32 100644 --- a/src/selftest/cases/60-hwlab-baseline-contract.ts +++ b/src/selftest/cases/60-hwlab-baseline-contract.ts @@ -238,7 +238,7 @@ function runPayload(context: SelfTestContext, backendProfile: BackendProfile, se return { tenantId: "hwlab", projectId: "pikasTech/HWLAB", - workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB" }, + workspaceRef: { kind: "opaque", path: "." }, sessionRef: { sessionId, conversationId: sessionId }, providerId: "G14", backendProfile, diff --git a/src/selftest/cases/76-aipod-spec.ts b/src/selftest/cases/76-aipod-spec.ts index 3d41886..d50141d 100644 --- a/src/selftest/cases/76-aipod-spec.ts +++ b/src/selftest/cases/76-aipod-spec.ts @@ -5,7 +5,7 @@ import { ManagerClient } from "../../mgr/client.js"; import { startManagerServer } from "../../mgr/server.js"; import { MemoryAgentRunStore } from "../../mgr/store.js"; import type { JsonRecord } from "../../common/types.js"; -import { validateResourceBundleRef } from "../../common/validation.js"; +import { validatePrimaryWorkspaceContract, validateResourceBundleRef } from "../../common/validation.js"; import { classifyGitStderr, resolveGitBundleFetchSource } from "../../runner/resource-bundle.js"; import { assertNoSecretLeak, loadArtificerImageRef, type SelfTestCase } from "../harness.js"; @@ -40,10 +40,7 @@ const selfTest: SelfTestCase = async (context) => { 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"); + assert.deepEqual(workspaceRef, { kind: "opaque", path: "." }); 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"); @@ -92,6 +89,11 @@ const selfTest: SelfTestCase = async (context) => { 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 primaryContractBundle = validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", ref: "master", bundles: [{ subpath: "tools", targetPath: "tools" }] }); + assert.ok(primaryContractBundle); + assert.doesNotThrow(() => validatePrimaryWorkspaceContract({ kind: "opaque", path: "." }, primaryContractBundle)); + assert.throws(() => validatePrimaryWorkspaceContract({ kind: "opaque", path: ".", repo: "pikasTech/unidesk" }, primaryContractBundle), /only supports kind and path/u); + assert.throws(() => validatePrimaryWorkspaceContract({ kind: "opaque", path: ".", branch: "master" }, primaryContractBundle), /only supports kind and path/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); diff --git a/src/selftest/cases/79-primary-workspace.ts b/src/selftest/cases/79-primary-workspace.ts index 815a661..e9629d8 100644 --- a/src/selftest/cases/79-primary-workspace.ts +++ b/src/selftest/cases/79-primary-workspace.ts @@ -1,10 +1,11 @@ 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 { access, mkdir, readFile, rename, 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 { stableHash } from "../../common/validation.js"; import { ManagerClient } from "../../mgr/client.js"; import { startManagerServer } from "../../mgr/server.js"; import { MemoryAgentRunStore } from "../../mgr/store.js"; @@ -22,7 +23,7 @@ const selfTest: SelfTestCase = async (context) => { 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 workspaceRef: WorkspaceRef = { kind: "opaque", path: "." }; const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: primary.path, @@ -33,6 +34,7 @@ const selfTest: SelfTestCase = async (context) => { 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" }; + await mkdir(workspacePath, { recursive: true }); const first = await materializeResourceBundle(resourceBundleRef, workspaceRef, env); assert.ok(first); await access(path.join(workspacePath, ".git")); @@ -44,23 +46,51 @@ const selfTest: SelfTestCase = async (context) => { 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.headCommit, primary.commitId); assert.equal(firstPrimary.sourceContract, "resourceBundleRef"); + assert.equal(firstPrimary.workspaceLifecycle, "first-materialization"); + assert.equal(firstPrimary.resolutionAuthority, "declared-commit"); assert.equal(firstPrimary.writable, true); + const provenancePath = path.join(workspacePath, ".git", "agentrun-primary-workspace.json"); + const persistedProvenance = JSON.parse(await readFile(provenancePath, "utf8")) as JsonRecord; + assert.equal(persistedProvenance.resolutionAuthority, "declared-commit"); + assert.equal(((persistedProvenance.resolvedSource as JsonRecord).commitId), primary.commitId); - await writeFile(path.join(workspacePath, "stale-from-previous-attempt.txt"), "stale\n", "utf8"); + await writeFile(path.join(workspacePath, "dirty-from-previous-attempt.txt"), "preserve me\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(await readFile(path.join(workspacePath, "dirty-from-previous-attempt.txt"), "utf8"), "preserve me\n"); + assert.equal((replay.event.primaryWorkspace as JsonRecord).workspaceLifecycle, "continuation-reuse"); 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 }), + () => materializeResourceBundle({ ...resourceBundleRef, ref: "changed-source-ref" }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_source_contract" }), + (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /declared source conflicts/u.test(error.message), + ); + await assert.rejects( + () => materializeResourceBundle({ ...resourceBundleRef, bundles: [{ ...resourceBundleRef.bundles[0]!, targetPath: "alternate-tools" }] }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_assembly_contract" }), + (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /assembly contract conflicts/u.test(error.message), + ); + const tamperedProvenance = JSON.parse(await readFile(provenancePath, "utf8")) as JsonRecord; + (tamperedProvenance.resolvedSource as JsonRecord).treeId = "0000000000000000000000000000000000000000"; + const tamperedBody = { ...tamperedProvenance }; + delete tamperedBody.provenanceHash; + tamperedProvenance.provenanceHash = stableHash(tamperedBody); + await writeFile(provenancePath, `${JSON.stringify(tamperedProvenance, null, 2)}\n`, "utf8"); + await assert.rejects( + () => materializeResourceBundle(resourceBundleRef, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_tampered_frozen_tree" }), + (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /frozen source tree conflicts/u.test(error.message), + ); + + const missingCredentialWorkspace = path.join(context.tmp, "primary-workspace-missing-credential"); + await assert.rejects( + () => materializeResourceBundle({ ...resourceBundleRef, credentialRef: { name: "missing-resource-git", keys: ["id_ed25519", "known_hosts"] } }, workspaceRef, { ...env, AGENTRUN_WORKSPACE_PATH: missingCredentialWorkspace, 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" }), + () => materializeResourceBundle(missingRef, workspaceRef, { ...env, AGENTRUN_WORKSPACE_PATH: path.join(context.tmp, "primary-workspace-missing-ref"), 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; @@ -70,6 +100,54 @@ const selfTest: SelfTestCase = async (context) => { }, ); + const mutablePrimary = await createRepo(context, "mutable-primary-source", { + "AGENTS.md": "# Frozen primary workspace\n", + "package.json": "{\"name\":\"mutable-primary-workspace-selftest\"}\n", + }); + const mutableBundle: ResourceBundleRef = { + kind: "gitbundle", + repoUrl: mutablePrimary.path, + ref: mutablePrimary.branch, + bundles: [{ name: "addon", repoUrl: addon.path, commitId: addon.commitId, subpath: "capability", targetPath: "tools" }], + }; + const mutableWorkspacePath = path.join(context.tmp, "mutable-primary-workspace"); + const mutableEnv = { AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "mutable-primary-assembly"), AGENTRUN_WORKSPACE_PATH: mutableWorkspacePath, AGENTRUN_RUN_ID: "run_mutable_primary_first" }; + const mutableFirst = await materializeResourceBundle(mutableBundle, workspaceRef, mutableEnv); + assert.ok(mutableFirst); + const mutableFirstPrimary = mutableFirst.event.primaryWorkspace as JsonRecord; + assert.equal(mutableFirstPrimary.sourceCommit, mutablePrimary.commitId); + assert.equal(mutableFirstPrimary.resolutionAuthority, "runner-first-materialization"); + + const workspaceSuccessorCommit = await commitFile(mutableWorkspacePath, "workspace-successor.txt", "workspace successor\n", "workspace successor"); + await writeFile(path.join(mutableWorkspacePath, "AGENTS.md"), "# Dirty continuation state\n", "utf8"); + await writeFile(path.join(mutableWorkspacePath, "tools", "probe"), "workspace-owned bundle target\n", "utf8"); + const advancedUpstreamCommit = await commitFile(mutablePrimary.path, "upstream-advanced.txt", "upstream advanced\n", "upstream advances mutable ref"); + assert.notEqual(advancedUpstreamCommit, mutablePrimary.commitId); + assert.notEqual(workspaceSuccessorCommit, advancedUpstreamCommit); + await rename(mutablePrimary.path, `${mutablePrimary.path}-unavailable-after-advance`); + + const mutableReplay = await materializeResourceBundle(mutableBundle, workspaceRef, { ...mutableEnv, AGENTRUN_RUN_ID: "run_mutable_primary_continuation" }); + assert.ok(mutableReplay); + const mutableReplayPrimary = mutableReplay.event.primaryWorkspace as JsonRecord; + assert.equal(mutableReplayPrimary.sourceCommit, mutablePrimary.commitId); + assert.equal(mutableReplayPrimary.headCommit, workspaceSuccessorCommit); + assert.equal(mutableReplayPrimary.headIsFrozenSource, false); + assert.equal(mutableReplayPrimary.workspaceLifecycle, "continuation-reuse"); + assert.equal(mutableReplayPrimary.resolutionAuthority, "runner-first-materialization"); + assert.equal((await execFile("git", ["rev-parse", "HEAD"], { cwd: mutableWorkspacePath })).stdout.trim(), workspaceSuccessorCommit); + assert.equal(await readFile(path.join(mutableWorkspacePath, "AGENTS.md"), "utf8"), "# Dirty continuation state\n"); + assert.equal(await readFile(path.join(mutableWorkspacePath, "tools", "probe"), "utf8"), "workspace-owned bundle target\n"); + assert.match((await execFile("git", ["status", "--porcelain"], { cwd: mutableWorkspacePath })).stdout, /AGENTS\.md/u); + + const unownedWorkspace = path.join(context.tmp, "nonempty-workspace-without-provenance"); + await mkdir(unownedWorkspace, { recursive: true }); + await writeFile(path.join(unownedWorkspace, "user-file.txt"), "must not be deleted\n", "utf8"); + await assert.rejects( + () => materializeResourceBundle(resourceBundleRef, workspaceRef, { ...env, AGENTRUN_WORKSPACE_PATH: unownedWorkspace, AGENTRUN_RUN_ID: "run_unowned_workspace" }), + (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /missing AgentRun provenance/u.test(error.message), + ); + assert.equal(await readFile(path.join(unownedWorkspace, "user-file.txt"), "utf8"), "must not be deleted\n"); + const store = new MemoryAgentRunStore(); const server = await startManagerServer({ port: 0, @@ -91,7 +169,7 @@ const selfTest: SelfTestCase = async (context) => { const run = await client.post("/api/v1/runs", { tenantId: "unidesk", projectId: "pikasTech/unidesk", - workspaceRef: { kind: "opaque", path: ".", branch: "missing-primary-ref" }, + workspaceRef: { kind: "opaque", path: "." }, sessionRef: null, resourceBundleRef: missingRef, providerId: "NC01", @@ -126,7 +204,16 @@ const selfTest: SelfTestCase = async (context) => { tests: [ "primary-workspace-exact-source-materialization", "primary-workspace-provenance-and-declared-origin", - "primary-workspace-retry-idempotent-replacement", + "primary-workspace-empty-root-atomic-installation", + "primary-workspace-continuation-preserves-dirty-tree", + "primary-workspace-continuation-freezes-mutable-ref", + "primary-workspace-continuation-does-not-fetch", + "primary-workspace-continuation-preserves-successor-commit", + "primary-workspace-continuation-reuses-bundle-provenance", + "primary-workspace-source-contract-conflict-fails-closed", + "primary-workspace-assembly-contract-conflict-fails-closed", + "primary-workspace-frozen-tree-provenance-verified", + "primary-workspace-nonempty-unowned-root-fails-closed", "primary-workspace-credential-missing-typed-blocker", "primary-workspace-ref-not-found-first-error", "partial-bundle-cannot-mask-primary-failure", @@ -151,4 +238,13 @@ async function createRepo(context: SelfTestContext, name: string, files: Record< return { path: repo, branch, commitId }; } +async function commitFile(repo: string, relativePath: string, text: string, message: string): Promise { + const file = path.join(repo, relativePath); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, text, "utf8"); + await execFile("git", ["add", relativePath], { cwd: repo }); + await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", message], { cwd: repo }); + return (await execFile("git", ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim(); +} + export default selfTest;