@@ -28,9 +28,22 @@ export interface RunnerWorkspaceApplyRequest {
2828 artifactRefs : RunnerWorkspaceArtifactRef [ ]
2929 workspaceRoot : string
3030 writablePaths : string [ ]
31+ seedIdentity ?: RunnerWorkspaceSeedIdentity
3132 verify ?: ( ) => Promise < void >
3233}
3334
35+ export interface RunnerWorkspaceSeedIdentity {
36+ content_digest : { algorithm : "sha256" ; value : string }
37+ git ?: { head : string }
38+ }
39+
40+ export interface RunnerWorkspaceApplyFailureEvidence {
41+ expected_identity ?: RunnerWorkspaceSeedIdentity
42+ actual_identity : RunnerWorkspaceSeedIdentity
43+ patch : { artifact_path : string ; sha256 : string }
44+ changed_files : { artifact_path : string ; sha256 : string }
45+ }
46+
3447export interface RunnerWorkspaceApplyResult {
3548 schema : "wp-codebox/runner-workspace-apply-result/v1"
3649 status : "applied" | "no-op"
@@ -72,15 +85,27 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq
7285
7386 const changed = parseChangedFiles ( changedRaw )
7487 validateChangedFiles ( changed , request . writablePaths )
88+ const failureEvidence = runnerWorkspaceFailureEvidence ( request . seedIdentity , await runnerWorkspaceIdentity ( workspaceRoot ) , artifactRoot , patchPath , patch , changedPath , changedRaw )
89+ if ( request . seedIdentity ) {
90+ if ( ! sameIdentity ( request . seedIdentity , failureEvidence . actual_identity ) ) {
91+ throw applyFailure ( "Runner workspace seed identity does not match the host workspace; refusing to apply patch." , {
92+ ...failureEvidence ,
93+ } )
94+ }
95+ }
7596 if ( changed . length === 0 ) {
7697 if ( patch . trim ( ) ) throw new Error ( "Canonical patch is non-empty but changed-files declares no changes." )
7798 return { schema : "wp-codebox/runner-workspace-apply-result/v1" , status : "no-op" , changedFiles : [ ] }
7899 }
79100 if ( ! patch . trim ( ) ) throw new Error ( "Canonical changed-files declares changes but patch is empty." )
80101 validatePatchPaths ( patch , changed )
81102
82- await execGit ( workspaceRoot , [ "apply" , "--check" , "--whitespace=error" , "--" , patchPath ] )
83- await execGit ( workspaceRoot , [ "apply" , "--whitespace=error" , "--" , patchPath ] )
103+ try {
104+ await execGit ( workspaceRoot , [ "apply" , "--check" , "--whitespace=error" , "--" , patchPath ] )
105+ await execGit ( workspaceRoot , [ "apply" , "--whitespace=error" , "--" , patchPath ] )
106+ } catch ( error ) {
107+ throw applyFailure ( error instanceof Error ? error . message : String ( error ) , failureEvidence )
108+ }
84109 const files = await snapshotWorkspace ( workspaceRoot )
85110 validateAppliedWorkspace ( baseline , files , changed )
86111 if ( request . verify ) await request . verify ( )
@@ -98,6 +123,36 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq
98123 }
99124}
100125
126+ export async function runnerWorkspaceIdentity ( root : string ) : Promise < RunnerWorkspaceSeedIdentity > {
127+ const digest = createHash ( "sha256" )
128+ async function visit ( directory : string ) : Promise < void > {
129+ for ( const entry of ( await readdir ( directory , { withFileTypes : true } ) ) . sort ( ( left , right ) => left . name . localeCompare ( right . name ) ) ) {
130+ if ( [ ".git" , ".codebox" , "node_modules" , "vendor" , "dist" , "build" , "coverage" , ".cache" ] . includes ( entry . name ) ) continue
131+ const absolute = resolve ( directory , entry . name )
132+ const path = relative ( root , absolute ) . replaceAll ( "\\" , "/" )
133+ const stat = await lstat ( absolute )
134+ if ( excludedSeedFile ( path ) ) continue
135+ if ( stat . isSymbolicLink ( ) || ( ! stat . isDirectory ( ) && ! stat . isFile ( ) ) ) throw new Error ( `Runner workspace contains an unsupported path type: ${ path } ` )
136+ if ( stat . isDirectory ( ) ) {
137+ digest . update ( `directory\0${ path } \n` )
138+ await visit ( absolute )
139+ continue
140+ }
141+ const bytes = await readFile ( absolute )
142+ digest . update ( `file\0${ path } \0${ ( stat . mode & 0o111 ? 0o755 : 0o644 ) . toString ( 8 ) } \0${ bytes . length } \n` )
143+ digest . update ( bytes )
144+ }
145+ }
146+ await visit ( root )
147+ const identity : RunnerWorkspaceSeedIdentity = { content_digest : { algorithm : "sha256" , value : digest . digest ( "hex" ) } }
148+ try {
149+ const { stdout } = await execFileAsync ( "git" , [ "rev-parse" , "HEAD" ] , { cwd : root , maxBuffer : 1024 } )
150+ const head = stdout . trim ( )
151+ if ( / ^ [ a - f 0 - 9 ] { 40 } $ / i. test ( head ) ) identity . git = { head }
152+ } catch { /* A non-git workspace still has a content identity. */ }
153+ return identity
154+ }
155+
101156/** Ensures checks did not mutate approved output or introduce unrelated files. */
102157export async function verifyRunnerWorkspaceIntegrity ( snapshot : RunnerWorkspaceIntegritySnapshot ) : Promise < void > {
103158 const current = await snapshotWorkspace ( snapshot . workspaceRoot )
@@ -217,6 +272,34 @@ function validateAppliedWorkspace(baseline: RunnerWorkspacePublicationFile[], cu
217272 }
218273}
219274
275+ function sameIdentity ( expected : RunnerWorkspaceSeedIdentity , actual : RunnerWorkspaceSeedIdentity ) : boolean {
276+ return expected . content_digest . algorithm === "sha256"
277+ && expected . content_digest . value === actual . content_digest . value
278+ && ( ! expected . git ?. head || expected . git . head === actual . git ?. head )
279+ }
280+
281+ function runnerWorkspaceFailureEvidence ( expected : RunnerWorkspaceSeedIdentity | undefined , actual : RunnerWorkspaceSeedIdentity , artifactRoot : string , patchPath : string , patch : string , changedPath : string , changedRaw : string ) : RunnerWorkspaceApplyFailureEvidence {
282+ return {
283+ ...( expected ? { expected_identity : expected } : { } ) ,
284+ actual_identity : actual ,
285+ patch : { artifact_path : relative ( artifactRoot , patchPath ) . replaceAll ( "\\" , "/" ) , sha256 : createHash ( "sha256" ) . update ( patch ) . digest ( "hex" ) } ,
286+ changed_files : { artifact_path : relative ( artifactRoot , changedPath ) . replaceAll ( "\\" , "/" ) , sha256 : createHash ( "sha256" ) . update ( changedRaw ) . digest ( "hex" ) } ,
287+ }
288+ }
289+
290+ function excludedSeedFile ( path : string ) : boolean {
291+ const name = path . split ( "/" ) . at ( - 1 ) ?. toLowerCase ( ) ?? ""
292+ return ( name === ".env" || ( name . startsWith ( ".env." ) && name !== ".env.example" ) )
293+ || [ ".npmrc" , ".yarnrc.yml" , ".pypirc" , ".netrc" , "auth.json" , "credentials" , "credentials.json" , "secrets.json" , "token.json" , "id_rsa" , "id_ed25519" , "id_ecdsa" , "id_dsa" ] . includes ( name )
294+ || / \. (?: p e m | k e y | p 1 2 | p f x ) $ / i. test ( name )
295+ }
296+
297+ function applyFailure ( message : string , evidence : RunnerWorkspaceApplyFailureEvidence ) : Error {
298+ const error = new Error ( message ) as Error & { evidence ?: RunnerWorkspaceApplyFailureEvidence }
299+ error . evidence = evidence
300+ return error
301+ }
302+
220303async function execGit ( cwd : string , args : string [ ] ) : Promise < void > {
221304 try {
222305 await execFileAsync ( "git" , args , { cwd, maxBuffer : MAX_PATCH_BYTES } )
0 commit comments