diff --git a/.github/workflows/client-v1-conformance.yml b/.github/workflows/client-v1-conformance.yml index 9662a44c..fe407260 100644 --- a/.github/workflows/client-v1-conformance.yml +++ b/.github/workflows/client-v1-conformance.yml @@ -1175,18 +1175,18 @@ jobs: $trustedHarnessModules = @( @('scripts\contract-canary.mjs', 40618, 'a4c2fe0a5eb6a5ff4653de5374c34c0fb46907c6806a5d23b86d8b37206ef958'), @('scripts\executable-resolution.mjs', 9154, '31e3c412ff8c835f14522f36a59e91f4a4ba82913210ae8e3b4455217503f430'), - @('scripts\owned-temp-directory.mjs', 6965, 'a9c55c85cf2b7d70310d278bafd2c8e7695d66f4ae38b9c3f1f12fce0b442095'), + @('scripts\owned-temp-directory.mjs', 7762, '95f546ef9ed614f2a0f55d356ddfc54c943fc53b595b4eebebfcbd4db68e5c0b'), @('scripts\phase1-artifact-secret-scan.mjs', 21183, 'be0ec302b9c4372f232d6bd1efcba873fd3380cc5de7f756cd0b9eeeec07222a'), @('scripts\phase1-conformance-lock.mjs', 48960, '92f981c43f75bc65c81e9e9ee16084aae658617b929d451a9db0d7c9e6bedbe2'), - @('scripts\phase1-conformance.mjs', 219211, 'daaa5fb660765b9d6c21bea4e4a3c9ebf1a92f14f052b7ed96593f579c7534ad'), + @('scripts\phase1-conformance.mjs', 219462, 'fd90e35308ea6b8dd0488cb874d00d7ad40aa16b33e38b46a9a76c3ea13d5056'), @('scripts\phase1-evidence-contract.mjs', 15088, '24180ae03835fa6aac45559682adb3c1e626bab76466eddc55b9e2300f0a2b7f'), @('scripts\phase1-evidence-runtime.mjs', 6572, 'b09a0d20abc7ebbc6289c57c985fd42b8a4b5d196895bc035b8fca40281d9315'), @('scripts\phase1-linux-secret-service.mjs', 4270, 'ddf834c6f57853c5116b4b1f345952a218ff0687c5d741737c68e20bc2ecda92'), @('scripts\phase1-macos-keychain.mjs', 5091, 'ab0c2dd08cf606d9502f5da206175707d471d99f484e8c8c79b5b08a5772b9a4'), @('scripts\phase1-process-supervisor.mjs', 3820, '16b51fb1a33b4bfef98daca549aacf5dc2d2c098cfbd664753b69c940d1e6f6c'), @('scripts\phase1-schema-v2-evidence.mjs', 52505, '0aede2ab3abd76fabf5ac61d64d2dbaaffa497c8647b82236403de16a47751c8'), - @('scripts\phase1-schema-v2-producer.mjs', 232829, '9b2b295170d37f45b5d860fd8cd82c65ab6538caa7cd8d599ce19c8680643174'), - @('scripts\process-owned-artifact-root.mjs', 11788, '426c2c8e36dc3bffddb35a565c07a60998b010660f6248ebc4264d9c4b502624'), + @('scripts\phase1-schema-v2-producer.mjs', 233186, '2e5fe8808230ad318aa86408bbad2004dbe4fd1c074f88de11fc581b227e0a51'), + @('scripts\process-owned-artifact-root.mjs', 13061, '103cc789f12a6bbde16b2414aecf05813d9d28a2c40c7d6eaa2073b86e8e5d77'), @('scripts\supervised-exec.mjs', 2875, 'a5edfd985b934d3b46247a0da3141682c411d30bb582edf87ae7b29791dad65b'), @('scripts\supervisor-status.mjs', 854, 'ac332ca7b6b040ecc846088bb3a6ad5e7112a0454eb3ea71d2a819d55e64254e') ) @@ -1910,7 +1910,7 @@ jobs: - name: Prepare trusted Unix supervisor if: matrix.platform != 'win32-x64' shell: bash - run: "set -euo pipefail\nnode --input-type=module <<'EOF'\nimport { createHash } from 'node:crypto';\nimport { lstatSync, readFileSync } from 'node:fs';\nconst expected = new Map([\n ['scripts/contract-canary.mjs', [40618, 'a4c2fe0a5eb6a5ff4653de5374c34c0fb46907c6806a5d23b86d8b37206ef958']],\n ['scripts/executable-resolution.mjs', [9154, '31e3c412ff8c835f14522f36a59e91f4a4ba82913210ae8e3b4455217503f430']],\n ['scripts/owned-temp-directory.mjs', [6965, 'a9c55c85cf2b7d70310d278bafd2c8e7695d66f4ae38b9c3f1f12fce0b442095']],\n ['scripts/phase1-artifact-secret-scan.mjs', [21183, 'be0ec302b9c4372f232d6bd1efcba873fd3380cc5de7f756cd0b9eeeec07222a']],\n ['scripts/phase1-conformance-lock.mjs', [48960, '92f981c43f75bc65c81e9e9ee16084aae658617b929d451a9db0d7c9e6bedbe2']],\n ['scripts/phase1-conformance.mjs', [219211, 'daaa5fb660765b9d6c21bea4e4a3c9ebf1a92f14f052b7ed96593f579c7534ad']],\n ['scripts/phase1-evidence-contract.mjs', [15088, '24180ae03835fa6aac45559682adb3c1e626bab76466eddc55b9e2300f0a2b7f']],\n ['scripts/phase1-evidence-runtime.mjs', [6572, 'b09a0d20abc7ebbc6289c57c985fd42b8a4b5d196895bc035b8fca40281d9315']],\n ['scripts/phase1-linux-secret-service.mjs', [4270, 'ddf834c6f57853c5116b4b1f345952a218ff0687c5d741737c68e20bc2ecda92']],\n ['scripts/phase1-macos-keychain.mjs', [5091, 'ab0c2dd08cf606d9502f5da206175707d471d99f484e8c8c79b5b08a5772b9a4']],\n ['scripts/phase1-process-supervisor.mjs', [3820, '16b51fb1a33b4bfef98daca549aacf5dc2d2c098cfbd664753b69c940d1e6f6c']],\n ['scripts/phase1-schema-v2-evidence.mjs', [52505, '0aede2ab3abd76fabf5ac61d64d2dbaaffa497c8647b82236403de16a47751c8']],\n ['scripts/phase1-schema-v2-producer.mjs', [232829, '9b2b295170d37f45b5d860fd8cd82c65ab6538caa7cd8d599ce19c8680643174']],\n ['scripts/process-owned-artifact-root.mjs', [11788, '426c2c8e36dc3bffddb35a565c07a60998b010660f6248ebc4264d9c4b502624']],\n ['scripts/supervised-exec.mjs', [2875, 'a5edfd985b934d3b46247a0da3141682c411d30bb582edf87ae7b29791dad65b']],\n ['scripts/supervisor-status.mjs', [854, 'ac332ca7b6b040ecc846088bb3a6ad5e7112a0454eb3ea71d2a819d55e64254e']],\n ['scripts/phase1-linux-secret-service.sh', [5650, '83ce19c0dd6da5002f6853fa37addb4fc2d39f3d17beee1b1c39e1fce232b476']],\n ['scripts/unix-artifact-handoff.c', [18704, '2a003f9aa1d1886b9a593371a73cb65fe3a4a8b703f1c59fec8a27694367b7fc']],\n ['scripts/unix-producer-command.sh', [3223, 'ce9ec2ff00947f3ec0db53f144c99d34bc27de6085062d00dccff7c934c2e3c8']],\n ['scripts/unix-producer-supervisor.sh', [29424, 'b73036415744c80ed27d5667f255ceea149096ca517b47c93a154299802206ff']],\n]);\nfor (const [path, [size, digest]] of expected) {\n const stats = lstatSync(path);\n const bytes = readFileSync(path);\n if (\n !stats.isFile() ||\n stats.isSymbolicLink() ||\n stats.nlink !== 1 ||\n bytes.byteLength !== size ||\n createHash('sha256').update(bytes).digest('hex') !== digest\n ) {\n throw new Error('Trusted Unix supervisor source bytes do not match');\n }\n}\nconsole.log('Frozen harness module graph verified.');\nEOF\nbroker_root=\"/tmp/opencoven-unix-broker\"\n(umask 077 && mkdir \"$broker_root\")\ncc -std=c11 -D_DARWIN_C_SOURCE -Wall -Wextra -Werror -O2 \\\n scripts/unix-artifact-handoff.c \\\n -o \"$broker_root/unix-artifact-handoff\"\nchmod 500 \"$broker_root/unix-artifact-handoff\"\n" + run: "set -euo pipefail\nnode --input-type=module <<'EOF'\nimport { createHash } from 'node:crypto';\nimport { lstatSync, readFileSync } from 'node:fs';\nconst expected = new Map([\n ['scripts/contract-canary.mjs', [40618, 'a4c2fe0a5eb6a5ff4653de5374c34c0fb46907c6806a5d23b86d8b37206ef958']],\n ['scripts/executable-resolution.mjs', [9154, '31e3c412ff8c835f14522f36a59e91f4a4ba82913210ae8e3b4455217503f430']],\n ['scripts/owned-temp-directory.mjs', [7762, '95f546ef9ed614f2a0f55d356ddfc54c943fc53b595b4eebebfcbd4db68e5c0b']],\n ['scripts/phase1-artifact-secret-scan.mjs', [21183, 'be0ec302b9c4372f232d6bd1efcba873fd3380cc5de7f756cd0b9eeeec07222a']],\n ['scripts/phase1-conformance-lock.mjs', [48960, '92f981c43f75bc65c81e9e9ee16084aae658617b929d451a9db0d7c9e6bedbe2']],\n ['scripts/phase1-conformance.mjs', [219462, 'fd90e35308ea6b8dd0488cb874d00d7ad40aa16b33e38b46a9a76c3ea13d5056']],\n ['scripts/phase1-evidence-contract.mjs', [15088, '24180ae03835fa6aac45559682adb3c1e626bab76466eddc55b9e2300f0a2b7f']],\n ['scripts/phase1-evidence-runtime.mjs', [6572, 'b09a0d20abc7ebbc6289c57c985fd42b8a4b5d196895bc035b8fca40281d9315']],\n ['scripts/phase1-linux-secret-service.mjs', [4270, 'ddf834c6f57853c5116b4b1f345952a218ff0687c5d741737c68e20bc2ecda92']],\n ['scripts/phase1-macos-keychain.mjs', [5091, 'ab0c2dd08cf606d9502f5da206175707d471d99f484e8c8c79b5b08a5772b9a4']],\n ['scripts/phase1-process-supervisor.mjs', [3820, '16b51fb1a33b4bfef98daca549aacf5dc2d2c098cfbd664753b69c940d1e6f6c']],\n ['scripts/phase1-schema-v2-evidence.mjs', [52505, '0aede2ab3abd76fabf5ac61d64d2dbaaffa497c8647b82236403de16a47751c8']],\n ['scripts/phase1-schema-v2-producer.mjs', [233186, '2e5fe8808230ad318aa86408bbad2004dbe4fd1c074f88de11fc581b227e0a51']],\n ['scripts/process-owned-artifact-root.mjs', [13061, '103cc789f12a6bbde16b2414aecf05813d9d28a2c40c7d6eaa2073b86e8e5d77']],\n ['scripts/supervised-exec.mjs', [2875, 'a5edfd985b934d3b46247a0da3141682c411d30bb582edf87ae7b29791dad65b']],\n ['scripts/supervisor-status.mjs', [854, 'ac332ca7b6b040ecc846088bb3a6ad5e7112a0454eb3ea71d2a819d55e64254e']],\n ['scripts/phase1-linux-secret-service.sh', [5650, '83ce19c0dd6da5002f6853fa37addb4fc2d39f3d17beee1b1c39e1fce232b476']],\n ['scripts/unix-artifact-handoff.c', [18704, '2a003f9aa1d1886b9a593371a73cb65fe3a4a8b703f1c59fec8a27694367b7fc']],\n ['scripts/unix-producer-command.sh', [3223, 'ce9ec2ff00947f3ec0db53f144c99d34bc27de6085062d00dccff7c934c2e3c8']],\n ['scripts/unix-producer-supervisor.sh', [29424, 'b73036415744c80ed27d5667f255ceea149096ca517b47c93a154299802206ff']],\n]);\nfor (const [path, [size, digest]] of expected) {\n const stats = lstatSync(path);\n const bytes = readFileSync(path);\n if (\n !stats.isFile() ||\n stats.isSymbolicLink() ||\n stats.nlink !== 1 ||\n bytes.byteLength !== size ||\n createHash('sha256').update(bytes).digest('hex') !== digest\n ) {\n throw new Error('Trusted Unix supervisor source bytes do not match');\n }\n}\nconsole.log('Frozen harness module graph verified.');\nEOF\nbroker_root=\"/tmp/opencoven-unix-broker\"\n(umask 077 && mkdir \"$broker_root\")\ncc -std=c11 -D_DARWIN_C_SOURCE -Wall -Wextra -Werror -O2 \\\n scripts/unix-artifact-handoff.c \\\n -o \"$broker_root/unix-artifact-handoff\"\nchmod 500 \"$broker_root/unix-artifact-handoff\"\n" - name: Compute reviewed Unix tool path id: unix-tool-path if: matrix.platform != 'win32-x64' diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index f4e6598e..ae233292 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -1,5 +1,33 @@ # Phase 1 real-authority conformance +## Execution-root cleanup diagnostic checkpoint + +Protected [run 35125287541](https://github.com/OpenCoven/chat/actions/runs/35125287541) +used Chat #305 (`1f69306293f8caf873e0a2d6459a5c402e77600a`) and SDK #291 +(`404c2bf898330cff10e0402646e83e6e65d17869`). Linux and macOS records passed +independent identity, timing, privacy and all 197 ordered assertions each. Windows +failed at `phase1.stage.execution-root-cleanup.failed`, before isolation validation, +and emitted no record. Downstream validation, aggregation and attestation were +skipped. This result does not prove that the earlier isolation failure is repaired. + +Cleanup diagnostics classify actual operation failures using private identity maps: +`child-terminate`, `supervisor-wait`, `child-kill`, `child-reap`, +`tracked-set-changed`, `root-precondition`, `root-rename`, `root-postrename`, +`entry-stat`, `leaf-remove`, `directory-enumerate`, `directory-remove`, +`multiple`, and `unknown`. Public IDs use the prefix +`phase1.stage.execution-root-cleanup.`. Native messages, paths and PIDs stay in +private errors; messages, public category properties and arbitrary aggregate causes +do not establish a category. Several cleanup failures report `multiple`. + +The diagnostic change preserves original private errors, reverse child cleanup, +existing grace periods, supervisor tree ownership, primary infrastructure-failure +precedence, root ownership checks before and after rename, and missing-entry and +symlink behavior. It does not repair the underlying Windows failure. Reviewed +source and binding delivery, matching SDK rebinding, both scope rotations, and a +fresh protected run remain necessary to identify that failure and prove acceptance. + +## Prior owner-cleanup investigation + Windows owner-cleanup follow-up: [CI 35095826197](https://github.com/OpenCoven/chat/actions/runs/35095826197) passed ten jobs and failed in the second lifecycle child launch at `operation=job-assignment`, `nativeCode=5` (access denied). The target job had @@ -163,8 +191,8 @@ assignment, RPC decoder and primary/secondary exception pipeline. This fixture correction does not establish the protected installation failure's cause or relax the round-trip assertions. -The lock now selects reviewed source `ffa954c2678bd933d7057d526b5fc4ed67a16f32`, -tree `3d667c7d816fe3fac088b6865b46756a84581beb`, including all 25 governed files +The lock now selects reviewed source `7dda439daa59ee350ff12cd195ff169b52e1085c`, +tree `347c0b5300498d612f5411c1aee3372731ffc4f1`, including all 25 governed files and ten production deltas. The checkout regression exercises all five labels from that immutable revision. SDK rebinding, both scope rotations and fresh protected validation remain required; this binding alone is not acceptance. @@ -1969,21 +1997,21 @@ revision authorities can therefore have different workflow hashes: | File | Bytes | SHA-256 | | --- | ---: | --- | -| `.github/workflows/client-v1-conformance.yml` | 177,934 | `5a43c25032b4eb040c1f3e378dc6456b1ef4e5bdeb36d5c58f71d8ec9d43c3d9` | +| `.github/workflows/client-v1-conformance.yml` | 177,934 | `73f665797b6d21de149949ca532bb814ac114a3682cff14c004919d95e3be2bb` | | `scripts/contract-canary.mjs` | 40,618 | `a4c2fe0a5eb6a5ff4653de5374c34c0fb46907c6806a5d23b86d8b37206ef958` | | `scripts/executable-resolution.mjs` | 9,154 | `31e3c412ff8c835f14522f36a59e91f4a4ba82913210ae8e3b4455217503f430` | -| `scripts/owned-temp-directory.mjs` | 6,965 | `a9c55c85cf2b7d70310d278bafd2c8e7695d66f4ae38b9c3f1f12fce0b442095` | +| `scripts/owned-temp-directory.mjs` | 7,762 | `95f546ef9ed614f2a0f55d356ddfc54c943fc53b595b4eebebfcbd4db68e5c0b` | | `scripts/phase1-artifact-secret-scan.mjs` | 21,183 | `be0ec302b9c4372f232d6bd1efcba873fd3380cc5de7f756cd0b9eeeec07222a` | | `scripts/phase1-conformance-lock.mjs` | 48,960 | `92f981c43f75bc65c81e9e9ee16084aae658617b929d451a9db0d7c9e6bedbe2` | -| `scripts/phase1-conformance.mjs` | 219,211 | `daaa5fb660765b9d6c21bea4e4a3c9ebf1a92f14f052b7ed96593f579c7534ad` | +| `scripts/phase1-conformance.mjs` | 219,462 | `fd90e35308ea6b8dd0488cb874d00d7ad40aa16b33e38b46a9a76c3ea13d5056` | | `scripts/phase1-evidence-contract.mjs` | 15,088 | `24180ae03835fa6aac45559682adb3c1e626bab76466eddc55b9e2300f0a2b7f` | | `scripts/phase1-evidence-runtime.mjs` | 6,572 | `b09a0d20abc7ebbc6289c57c985fd42b8a4b5d196895bc035b8fca40281d9315` | | `scripts/phase1-linux-secret-service.mjs` | 4,270 | `ddf834c6f57853c5116b4b1f345952a218ff0687c5d741737c68e20bc2ecda92` | | `scripts/phase1-macos-keychain.mjs` | 5,091 | `ab0c2dd08cf606d9502f5da206175707d471d99f484e8c8c79b5b08a5772b9a4` | | `scripts/phase1-process-supervisor.mjs` | 3,820 | `16b51fb1a33b4bfef98daca549aacf5dc2d2c098cfbd664753b69c940d1e6f6c` | | `scripts/phase1-schema-v2-evidence.mjs` | 52,505 | `0aede2ab3abd76fabf5ac61d64d2dbaaffa497c8647b82236403de16a47751c8` | -| `scripts/phase1-schema-v2-producer.mjs` | 232,829 | `9b2b295170d37f45b5d860fd8cd82c65ab6538caa7cd8d599ce19c8680643174` | -| `scripts/process-owned-artifact-root.mjs` | 11,788 | `426c2c8e36dc3bffddb35a565c07a60998b010660f6248ebc4264d9c4b502624` | +| `scripts/phase1-schema-v2-producer.mjs` | 233,186 | `2e5fe8808230ad318aa86408bbad2004dbe4fd1c074f88de11fc581b227e0a51` | +| `scripts/process-owned-artifact-root.mjs` | 13,061 | `103cc789f12a6bbde16b2414aecf05813d9d28a2c40c7d6eaa2073b86e8e5d77` | | `scripts/supervised-exec.mjs` | 2,875 | `a5edfd985b934d3b46247a0da3141682c411d30bb582edf87ae7b29791dad65b` | | `scripts/supervisor-status.mjs` | 854 | `ac332ca7b6b040ecc846088bb3a6ad5e7112a0454eb3ea71d2a819d55e64254e` | | `scripts/phase1-linux-secret-service.sh` | 5,650 | `83ce19c0dd6da5002f6853fa37addb4fc2d39f3d17beee1b1c39e1fce232b476` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 99282097..a8592c9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,44 @@ # Delivery roadmap and consolidation audit -## Current delivery and consolidation checkpoint, 2026-09-16 +## Current delivery and consolidation checkpoint, 2026-09-16 (Chat #305) + +Chat [#305](https://github.com/OpenCoven/chat/pull/305) landed at +`1f69306293f8caf873e0a2d6459a5c402e77600a`, followed by SDK +[#291](https://github.com/OpenCoven/sdk/pull/291) at +`404c2bf898330cff10e0402646e83e6e65d17869`. Both validator scopes were +rotated and read back at that SDK merge. Protected +[run 35125287541](https://github.com/OpenCoven/chat/actions/runs/35125287541) +failed on Windows at `phase1.stage.execution-root-cleanup.failed`. Linux and +macOS records independently passed identity, timing, privacy and all 197 ordered +assertions each. Windows emitted no record; downstream validation, aggregation +and attestation were skipped. Cleanup failure prevents later isolation validation, +so this result does not establish that the preceding isolation failure is repaired. + +The cleanup diagnostic follow-up distinguishes fourteen fixed categories at the +actual operation boundaries. It retains private causes, primary-failure precedence, +child termination order and grace periods, root identity/stamp checks, and symlink +and missing-entry behavior. It does not change permissions, limits, dependencies, +or acceptance criteria. Signed source binding, reviewed delivery, SDK rebinding, +scope rotation and fresh protected validation remain required. + +Chat [#308](https://github.com/OpenCoven/chat/pull/308) addresses superseded +Windows CI. Its old running Windows job was cancelled after the replacement push; +the replacement released the queue and is awaiting terminal success in +[run 35127584766](https://github.com/OpenCoven/chat/actions/runs/35127584766). +Issue [#306](https://github.com/OpenCoven/chat/issues/306) remains open until +that exact head passes and lands. Concurrent custody-diagnostic PR #307 requires +sequential integration and a refreshed binding; its older binding must not land. + +The worktree inventory currently contains eleven Chat and three SDK trees. +Preserve active, dirty, locked and ownership-uncertain trees, including the running +Chat #297 app. No retirement follows from age, cleanliness or a merged PR alone. +Chat #86 remains a parked draft requiring feature and vendor-artifact reconciliation; +WorkOS #303 remains active. The managed HPKE iterator authority gap and Project 9 +tracking refresh remain open. Candidate and counterpart identities remain frozen, +publication remains disabled, and no accepted aggregate exists. Consolidation is +incomplete; preserve this chat and active worktrees. + +## Historical delivery and consolidation checkpoint, 2026-09-16 (Chat #297) GitHub issues and repository roadmaps hold current ownership, blockers, and evidence. Original acceptance criteria and historical records below remain preserved. diff --git a/phase1-conformance.lock.json b/phase1-conformance.lock.json index 070d568e..36dd71aa 100644 --- a/phase1-conformance.lock.json +++ b/phase1-conformance.lock.json @@ -18,16 +18,16 @@ }, "harness": { "repository": "OpenCoven/chat", - "revision": "ffa954c2678bd933d7057d526b5fc4ed67a16f32" + "revision": "7dda439daa59ee350ff12cd195ff169b52e1085c" }, "harnessAuthority": { - "revision": "ffa954c2678bd933d7057d526b5fc4ed67a16f32", - "tree": "3d667c7d816fe3fac088b6865b46756a84581beb", + "revision": "7dda439daa59ee350ff12cd195ff169b52e1085c", + "tree": "347c0b5300498d612f5411c1aee3372731ffc4f1", "files": [ { "path": "scripts/phase1-conformance.mjs", - "blob": "327a6952259a7d171517c66367206a465f8a8c0f", - "sha256": "daaa5fb660765b9d6c21bea4e4a3c9ebf1a92f14f052b7ed96593f579c7534ad" + "blob": "c0954efeaa51edaddb843b27414e48e54a575981", + "sha256": "fd90e35308ea6b8dd0488cb874d00d7ad40aa16b33e38b46a9a76c3ea13d5056" }, { "path": "scripts/phase1-conformance-launcher.sh", @@ -51,13 +51,13 @@ }, { "path": "scripts/process-owned-artifact-root.mjs", - "blob": "72363bad5b70f5c8fa040707415218ef32fb9863", - "sha256": "426c2c8e36dc3bffddb35a565c07a60998b010660f6248ebc4264d9c4b502624" + "blob": "c3127190fdbfd082f79a1ce73b6778746f2c89b4", + "sha256": "103cc789f12a6bbde16b2414aecf05813d9d28a2c40c7d6eaa2073b86e8e5d77" }, { "path": "scripts/owned-temp-directory.mjs", - "blob": "e5677dc8751b713284334635683d7dd89847402e", - "sha256": "a9c55c85cf2b7d70310d278bafd2c8e7695d66f4ae38b9c3f1f12fce0b442095" + "blob": "fe9b011c8e6ac4f741f052dc15c7405bad390165", + "sha256": "95f546ef9ed614f2a0f55d356ddfc54c943fc53b595b4eebebfcbd4db68e5c0b" }, { "path": "scripts/phase1-conformance-lock.mjs", @@ -81,8 +81,8 @@ }, { "path": "scripts/phase1-schema-v2-producer.mjs", - "blob": "ad3605e1d7a72928dfca14a5063ed37625ddb613", - "sha256": "9b2b295170d37f45b5d860fd8cd82c65ab6538caa7cd8d599ce19c8680643174" + "blob": "76c8a0e31e55c8c1478dcb9657a1570d697b155c", + "sha256": "2e5fe8808230ad318aa86408bbad2004dbe4fd1c074f88de11fc581b227e0a51" }, { "path": "scripts/phase1-linux-secret-service.mjs", @@ -146,8 +146,8 @@ }, { "path": ".github/workflows/client-v1-conformance.yml", - "blob": "9662a44ce710dc2d0aa4989eb2bf7bd88000a445", - "sha256": "5a43c25032b4eb040c1f3e378dc6456b1ef4e5bdeb36d5c58f71d8ec9d43c3d9" + "blob": "fe4072609b4c3e967881f93d82f86de0c0d4f23a", + "sha256": "73f665797b6d21de149949ca532bb814ac114a3682cff14c004919d95e3be2bb" } ], "productionDeltas": [ diff --git a/scripts/owned-temp-directory.d.mts b/scripts/owned-temp-directory.d.mts index 44eb248d..96409a5f 100644 --- a/scripts/owned-temp-directory.d.mts +++ b/scripts/owned-temp-directory.d.mts @@ -18,3 +18,15 @@ export function createOwnedTempDirectory(options: { childSegments?: string[]; }): OwnedTempDirectoryContext; export function cleanupOwnedTempRoot(context: OwnedTempDirectoryContext): void; + +export function ownedTempCleanupFailureCategory( + error: unknown, +): + | 'root-precondition' + | 'root-rename' + | 'root-postrename' + | 'entry-stat' + | 'leaf-remove' + | 'directory-enumerate' + | 'directory-remove' + | 'unknown'; diff --git a/scripts/owned-temp-directory.mjs b/scripts/owned-temp-directory.mjs index e5677dc8..fe9b011c 100644 --- a/scripts/owned-temp-directory.mjs +++ b/scripts/owned-temp-directory.mjs @@ -167,23 +167,41 @@ function assertOwnedRootStillMatches(context) { } } +const cleanupFailureCategories = new WeakMap(); + +export function ownedTempCleanupFailureCategory(error) { + return cleanupFailureCategories.get(error) ?? 'unknown'; +} + +function cleanupOperation(category, operation) { + try { + return operation(); + } catch (error) { + if ((typeof error === 'object' && error !== null) || typeof error === 'function') { + cleanupFailureCategories.set(error, category); + } + throw error; + } +} + function removePathWithoutFollowingSymlinks(path) { - const stats = lstatIfExists(path); + const stats = cleanupOperation('entry-stat', () => lstatIfExists(path)); if (stats === undefined) { return; } if (stats.isSymbolicLink() || !stats.isDirectory()) { - unlinkSync(path); + cleanupOperation('leaf-remove', () => unlinkSync(path)); return; } - for (const entry of readdirSync(path)) { + const entries = cleanupOperation('directory-enumerate', () => readdirSync(path)); + for (const entry of entries) { removePathWithoutFollowingSymlinks(resolve(path, entry)); } - rmdirSync(path); + cleanupOperation('directory-remove', () => rmdirSync(path)); } export function createOwnedTempDirectory({ prefix, childSegments = [] } = {}) { @@ -197,32 +215,34 @@ export function createOwnedShortTempDirectory({ prefix, childSegments = [] } = { } export function cleanupOwnedTempRoot(context) { - assertOwnedRootStillMatches(context); + cleanupOperation('root-precondition', () => assertOwnedRootStillMatches(context)); const deletingRoot = resolve( context.parentPath, `${basename(context.rootPath)}.deleting-${process.pid}-${randomUUID()}`, ); - renameSync(context.rootPath, deletingRoot); + cleanupOperation('root-rename', () => renameSync(context.rootPath, deletingRoot)); - const renamedStats = lstatSync(deletingRoot); + cleanupOperation('root-postrename', () => { + const renamedStats = lstatSync(deletingRoot); - if (renamedStats.isSymbolicLink()) { - throw new Error(`Owned temp cleanup root must not be a symlink: ${deletingRoot}`); - } + if (renamedStats.isSymbolicLink()) { + throw new Error(`Owned temp cleanup root must not be a symlink: ${deletingRoot}`); + } - if (!renamedStats.isDirectory()) { - throw new Error(`Owned temp cleanup root must be a directory: ${deletingRoot}`); - } + if (!renamedStats.isDirectory()) { + throw new Error(`Owned temp cleanup root must be a directory: ${deletingRoot}`); + } - if (renamedStats.dev !== context.rootDevice || renamedStats.ino !== context.rootInode) { - throw new Error(`Owned temp cleanup root changed identity after rename: ${deletingRoot}`); - } + if (renamedStats.dev !== context.rootDevice || renamedStats.ino !== context.rootInode) { + throw new Error(`Owned temp cleanup root changed identity after rename: ${deletingRoot}`); + } - // The rename moved a directory; confirm it is still ours before the - // recursive delete, which is the only irreversible step in this file. - assertOwnershipStamp(deletingRoot, context, 'after rename'); + // The rename moved a directory; confirm it is still ours before the + // recursive delete, which is the only irreversible step in this file. + assertOwnershipStamp(deletingRoot, context, 'after rename'); + }); removePathWithoutFollowingSymlinks(deletingRoot); } diff --git a/scripts/phase1-conformance.mjs b/scripts/phase1-conformance.mjs index 327a6952..c0954efe 100644 --- a/scripts/phase1-conformance.mjs +++ b/scripts/phase1-conformance.mjs @@ -60,7 +60,11 @@ import { schemaV2SupervisorEnvironment, supervisorArtifactOutputPath, } from './phase1-schema-v2-producer.mjs'; -import { createProcessOwnedArtifactRoot } from './process-owned-artifact-root.mjs'; +import { + createProcessOwnedArtifactRoot, + PROCESS_CLEANUP_FAILURE_CATEGORIES, + processCleanupFailureCategory, +} from './process-owned-artifact-root.mjs'; import { configureSupervisedExecution, runSupervisedSync } from './supervised-exec.mjs'; import { parseSupervisorStatusFrame } from './supervisor-status.mjs'; @@ -815,6 +819,9 @@ const publicPhase1DiagnosticIds = new Set([ 'phase1.stage.evidence-validation.unknown', 'phase1.stage.evidence-retention.failed', 'phase1.stage.execution-root-cleanup.failed', + ...PROCESS_CLEANUP_FAILURE_CATEGORIES.map( + (category) => `phase1.stage.execution-root-cleanup.${category}`, + ), 'phase1.cave-authority.timeout', 'phase1.cave-authority.output-limit', 'phase1.cave-authority.spawn', @@ -6020,7 +6027,10 @@ export async function runPhase1Conformance( try { await executionRoot.cleanup(); } catch (error) { - cleanupFailure = new Error('phase1.stage.execution-root-cleanup.failed', { cause: error }); + cleanupFailure = new Error( + `phase1.stage.execution-root-cleanup.${processCleanupFailureCategory(error)}`, + { cause: error }, + ); } } diff --git a/scripts/phase1-schema-v2-producer.mjs b/scripts/phase1-schema-v2-producer.mjs index ad3605e1..76c8a0e3 100644 --- a/scripts/phase1-schema-v2-producer.mjs +++ b/scripts/phase1-schema-v2-producer.mjs @@ -65,7 +65,11 @@ import { serializeValidatedSchemaV2PlatformEvidence, verifySchemaV2ProducerCheckout, } from './phase1-schema-v2-evidence.mjs'; -import { createProcessOwnedArtifactRoot } from './process-owned-artifact-root.mjs'; +import { + createProcessOwnedArtifactRoot, + PROCESS_CLEANUP_FAILURE_CATEGORIES, + processCleanupFailureCategory, +} from './process-owned-artifact-root.mjs'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const defaultRetainedReport = resolve( @@ -693,6 +697,9 @@ const publicFailureDiagnosticSet = new Set([ 'phase1.stage.coven-identity.failed', 'phase1.stage.isolation.failed', 'phase1.stage.execution-root-cleanup.failed', + ...PROCESS_CLEANUP_FAILURE_CATEGORIES.map( + (category) => `phase1.stage.execution-root-cleanup.${category}`, + ), ]); const requiredAssertionSet = new Set(REQUIRED_PHASE1_ASSERTION_IDS); const approvedCommandFailureReasons = new Set([ @@ -1608,6 +1615,9 @@ export function classifyCavePreAssertionFailure(output) { } export function schemaV2FailureDiagnostic(error, activeStage) { + if (activeStage === 'phase1.stage.execution-root-cleanup.failed') { + return `phase1.stage.execution-root-cleanup.${processCleanupFailureCategory(error)}`; + } if ( error !== null && typeof error === 'object' && diff --git a/scripts/process-owned-artifact-root.d.mts b/scripts/process-owned-artifact-root.d.mts index e977e929..55ffa872 100644 --- a/scripts/process-owned-artifact-root.d.mts +++ b/scripts/process-owned-artifact-root.d.mts @@ -24,3 +24,18 @@ export function createProcessOwnedArtifactRoot(options: { terminationGraceMs?: number; shortPath?: boolean; }): ProcessOwnedArtifactRoot; + +export function processCleanupFailureCategory( + error: unknown, +): + | ReturnType + | 'child-terminate' + | 'supervisor-wait' + | 'child-kill' + | 'child-reap' + | 'tracked-set-changed' + | 'multiple'; + +export const PROCESS_CLEANUP_FAILURE_CATEGORIES: readonly ReturnType< + typeof processCleanupFailureCategory +>[]; diff --git a/scripts/process-owned-artifact-root.mjs b/scripts/process-owned-artifact-root.mjs index 72363bad..c3127190 100644 --- a/scripts/process-owned-artifact-root.mjs +++ b/scripts/process-owned-artifact-root.mjs @@ -22,6 +22,7 @@ import { cleanupOwnedTempRoot, createOwnedShortTempDirectory, createOwnedTempDirectory, + ownedTempCleanupFailureCategory, } from './owned-temp-directory.mjs'; const defaultTerminationGraceMs = 5_000; @@ -238,31 +239,68 @@ function waitForChildClose(child, timeoutMs) { }); } +export const PROCESS_CLEANUP_FAILURE_CATEGORIES = Object.freeze([ + 'child-terminate', + 'supervisor-wait', + 'child-kill', + 'child-reap', + 'tracked-set-changed', + 'root-precondition', + 'root-rename', + 'root-postrename', + 'entry-stat', + 'leaf-remove', + 'directory-enumerate', + 'directory-remove', + 'multiple', + 'unknown', +]); + +const cleanupFailureCategories = new WeakMap(); + +export function processCleanupFailureCategory(error) { + return cleanupFailureCategories.get(error) ?? ownedTempCleanupFailureCategory(error); +} + +function classifyCleanupFailure(error, category) { + if ((typeof error === 'object' && error !== null) || typeof error === 'function') { + cleanupFailureCategories.set(error, category); + } + return error; +} + async function terminateAndReapChild(child, terminationGraceMs) { const pid = child.pid; - - if (child.exitCode === null && child.signalCode === null) { - const signaled = child.kill('SIGTERM'); - if (!signaled && child.exitCode === null && child.signalCode === null) { - throw new Error(`Tracked child ${pid} could not be terminated.`); + let operation = 'child-terminate'; + try { + if (child.exitCode === null && child.signalCode === null) { + const signaled = child.kill('SIGTERM'); + if (!signaled && child.exitCode === null && child.signalCode === null) { + throw new Error(`Tracked child ${pid} could not be terminated.`); + } } - } - if (await waitForChildClose(child, terminationGraceMs)) { - return; - } + operation = 'supervisor-wait'; + if (await waitForChildClose(child, terminationGraceMs)) { + return; + } - if (child.__phase1SupervisorOwnsTree === true) { - throw new Error(`Tracked supervisor ${pid} did not complete owned-tree cleanup.`); - } + if (child.__phase1SupervisorOwnsTree === true) { + throw new Error(`Tracked supervisor ${pid} did not complete owned-tree cleanup.`); + } - const killed = child.kill('SIGKILL'); - if (!killed && child.exitCode === null && child.signalCode === null) { - throw new Error(`Tracked child ${pid} could not be killed.`); - } + operation = 'child-kill'; + const killed = child.kill('SIGKILL'); + if (!killed && child.exitCode === null && child.signalCode === null) { + throw new Error(`Tracked child ${pid} could not be killed.`); + } - if (!(await waitForChildClose(child, terminationGraceMs))) { - throw new Error(`Tracked child ${pid} could not be reaped.`); + operation = 'child-reap'; + if (!(await waitForChildClose(child, terminationGraceMs))) { + throw new Error(`Tracked child ${pid} could not be reaped.`); + } + } catch (error) { + throw classifyCleanupFailure(error, operation); } } @@ -364,7 +402,12 @@ export function createProcessOwnedArtifactRoot(options) { } if (trackedChildren.size > 0 && failures.length === 0) { - failures.push(new Error('Tracked children changed during cleanup; retry cleanup.')); + failures.push( + classifyCleanupFailure( + new Error('Tracked children changed during cleanup; retry cleanup.'), + 'tracked-set-changed', + ), + ); } if (trackedChildren.size === 0) { try { @@ -375,7 +418,10 @@ export function createProcessOwnedArtifactRoot(options) { } } if (failures.length > 0) { - throw new AggregateError(failures, 'Process-owned artifact root cleanup failed.'); + throw classifyCleanupFailure( + new AggregateError(failures, 'Process-owned artifact root cleanup failed.'), + failures.length === 1 ? processCleanupFailureCategory(failures[0]) : 'multiple', + ); } }, }; diff --git a/src/owned-temp-cleanup-diagnostics.test.ts b/src/owned-temp-cleanup-diagnostics.test.ts new file mode 100644 index 00000000..4a2551ae --- /dev/null +++ b/src/owned-temp-cleanup-diagnostics.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import * as ownedTemp from '../scripts/owned-temp-directory.mjs'; +import { + extractVerifiedRunnerDiagnostic, + publicPhase1FailureDiagnostic, +} from '../scripts/phase1-conformance.mjs'; +// @ts-expect-error The executable script intentionally has no declaration file. +import * as producer from '../scripts/phase1-schema-v2-producer.mjs'; + +const fault = vi.hoisted(() => ({ operation: '', renamed: false })); +const privateFailure = vi.hoisted(() => new Error('private-path-canary PID-987654 native-message')); + +vi.mock('node:fs', async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + renameSync: (...args: Parameters) => { + if (fault.operation === 'root-rename') throw privateFailure; + fs.renameSync(...args); + fault.renamed = true; + }, + lstatSync: (...args: Parameters) => { + if (fault.operation === 'root-precondition' && !fault.renamed) throw privateFailure; + if (fault.operation === 'root-postrename' && fault.renamed) throw privateFailure; + if (fault.operation === 'entry-stat' && String(args[0]).endsWith('diagnostic-leaf')) { + throw privateFailure; + } + return fs.lstatSync(...args); + }, + unlinkSync: (...args: Parameters) => { + if (fault.operation === 'leaf-remove') throw privateFailure; + return fs.unlinkSync(...args); + }, + readdirSync: (...args: Parameters) => { + if (fault.operation === 'directory-enumerate') throw privateFailure; + return fs.readdirSync(...args); + }, + rmdirSync: (...args: Parameters) => { + if (fault.operation === 'directory-remove') throw privateFailure; + return fs.rmdirSync(...args); + }, + }; +}); + +const roots: ReturnType[] = []; +afterEach(() => { + fault.operation = ''; + fault.renamed = false; + for (const root of roots.splice(0)) { + rmSync(root.rootPath, { recursive: true, force: true }); + for (const entry of readdirSync(root.parentPath)) { + if (entry.startsWith(`${basename(root.rootPath)}.deleting-`)) { + rmSync(resolve(root.parentPath, entry), { recursive: true, force: true }); + } + } + } +}); + +function classify(error: unknown) { + const classifier = Reflect.get(ownedTemp, 'ownedTempCleanupFailureCategory'); + return typeof classifier === 'function' ? classifier(error) : undefined; +} + +test.each([ + 'root-precondition', + 'root-rename', + 'root-postrename', + 'entry-stat', + 'leaf-remove', + 'directory-enumerate', + 'directory-remove', +])('classifies the actual %s failure without publishing native details', (operation) => { + const root = ownedTemp.createOwnedTempDirectory({ prefix: 'cleanup-diagnostic-test' }); + roots.push(root); + writeFileSync(resolve(root.rootPath, 'diagnostic-leaf'), 'test'); + fault.operation = operation; + let caught: unknown; + try { + ownedTemp.cleanupOwnedTempRoot(root); + } catch (error) { + caught = error; + } + expect(caught).toBe(privateFailure); + expect(classify(caught)).toBe(operation); + const diagnostic = producer.schemaV2FailureDiagnostic( + caught, + 'phase1.stage.execution-root-cleanup.failed', + ); + expect(diagnostic).toBe(`phase1.stage.execution-root-cleanup.${operation}`); + expect(publicPhase1FailureDiagnostic(new Error(diagnostic, { cause: caught }))).toBe(diagnostic); + expect(extractVerifiedRunnerDiagnostic(`phase1-conformance: ${diagnostic}`)).toBe(diagnostic); + expect(classify(caught)).not.toMatch(/canary|987654|native-message/); +}); + +test('does not accept forged public categories, messages or aggregate causes', () => { + const forged = Object.assign(new Error('root-rename'), { category: 'root-rename' }); + for (const error of [ + forged, + new AggregateError([forged]), + { cause: forged }, + null, + 'root-rename', + ]) { + expect(classify(error)).toBe('unknown'); + } +}); + +test('successful cleanup still removes the owned root', () => { + const root = ownedTemp.createOwnedTempDirectory({ prefix: 'cleanup-diagnostic-test' }); + roots.push(root); + ownedTemp.cleanupOwnedTempRoot(root); + expect(existsSync(root.rootPath)).toBe(false); +}); diff --git a/src/phase1-conformance-lock.test.ts b/src/phase1-conformance-lock.test.ts index 195eef80..cd0a87d2 100644 --- a/src/phase1-conformance-lock.test.ts +++ b/src/phase1-conformance-lock.test.ts @@ -80,13 +80,18 @@ const committedHarnessAuthority = JSON.parse( readFileSync(resolve(projectRoot, 'phase1-conformance.lock.json'), 'utf8'), ).harnessAuthority; const expectedBehaviorAuthority = { - revision: 'ffa954c2678bd933d7057d526b5fc4ed67a16f32', - tree: '3d667c7d816fe3fac088b6865b46756a84581beb', + revision: '7dda439daa59ee350ff12cd195ff169b52e1085c', + tree: '347c0b5300498d612f5411c1aee3372731ffc4f1', files: [ + { + path: 'scripts/owned-temp-directory.mjs', + blob: 'fe9b011c8e6ac4f741f052dc15c7405bad390165', + sha256: '95f546ef9ed614f2a0f55d356ddfc54c943fc53b595b4eebebfcbd4db68e5c0b', + }, { path: 'scripts/phase1-conformance.mjs', - blob: '327a6952259a7d171517c66367206a465f8a8c0f', - sha256: 'daaa5fb660765b9d6c21bea4e4a3c9ebf1a92f14f052b7ed96593f579c7534ad', + blob: 'c0954efeaa51edaddb843b27414e48e54a575981', + sha256: 'fd90e35308ea6b8dd0488cb874d00d7ad40aa16b33e38b46a9a76c3ea13d5056', }, { path: 'scripts/phase1-conformance-lock.mjs', @@ -100,8 +105,8 @@ const expectedBehaviorAuthority = { }, { path: 'scripts/phase1-schema-v2-producer.mjs', - blob: 'ad3605e1d7a72928dfca14a5063ed37625ddb613', - sha256: '9b2b295170d37f45b5d860fd8cd82c65ab6538caa7cd8d599ce19c8680643174', + blob: '76c8a0e31e55c8c1478dcb9657a1570d697b155c', + sha256: '2e5fe8808230ad318aa86408bbad2004dbe4fd1c074f88de11fc581b227e0a51', }, { path: 'scripts/unix-producer-supervisor.sh', @@ -130,13 +135,13 @@ const expectedBehaviorAuthority = { }, { path: '.github/workflows/client-v1-conformance.yml', - blob: '9662a44ce710dc2d0aa4989eb2bf7bd88000a445', - sha256: '5a43c25032b4eb040c1f3e378dc6456b1ef4e5bdeb36d5c58f71d8ec9d43c3d9', + blob: 'fe4072609b4c3e967881f93d82f86de0c0d4f23a', + sha256: '73f665797b6d21de149949ca532bb814ac114a3682cff14c004919d95e3be2bb', }, { path: 'scripts/process-owned-artifact-root.mjs', - blob: '72363bad5b70f5c8fa040707415218ef32fb9863', - sha256: '426c2c8e36dc3bffddb35a565c07a60998b010660f6248ebc4264d9c4b502624', + blob: 'c3127190fdbfd082f79a1ce73b6778746f2c89b4', + sha256: '103cc789f12a6bbde16b2414aecf05813d9d28a2c40c7d6eaa2073b86e8e5d77', }, ], } as const; diff --git a/src/process-cleanup-diagnostics.test.ts b/src/process-cleanup-diagnostics.test.ts new file mode 100644 index 00000000..50b28423 --- /dev/null +++ b/src/process-cleanup-diagnostics.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment node +import { ChildProcess } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { + extractVerifiedRunnerDiagnostic, + publicPhase1FailureDiagnostic, + runPublicPhase1StageAsync, +} from '../scripts/phase1-conformance.mjs'; +// @ts-expect-error The executable script intentionally has no declaration file. +import * as producer from '../scripts/phase1-schema-v2-producer.mjs'; +import * as artifactRoot from '../scripts/process-owned-artifact-root.mjs'; + +const roots: artifactRoot.ProcessOwnedArtifactRoot[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root.rootPath, { recursive: true, force: true }); +}); + +function root() { + const value = artifactRoot.createProcessOwnedArtifactRoot({ + prefix: 'process-cleanup-diagnostic', + terminationGraceMs: 1, + }); + roots.push(value); + return value; +} + +function child(pid = 987654) { + const value = new ChildProcess(); + Object.defineProperty(value, 'pid', { value: pid }); + value.kill = vi.fn(() => true); + return value; +} + +function classify(error: unknown) { + const classifier = Reflect.get(artifactRoot, 'processCleanupFailureCategory'); + return typeof classifier === 'function' ? classifier(error) : undefined; +} + +test.each(['child-terminate', 'supervisor-wait', 'child-kill', 'child-reap'])( + 'classifies %s and retains the root while the child remains live', + async (category) => { + const owned = root(); + const process = child(); + if (category === 'child-terminate') process.kill = vi.fn(() => false); + if (category === 'supervisor-wait') { + Object.assign(process, { __phase1SupervisorOwnsTree: true }); + } + if (category === 'child-kill') process.kill = vi.fn((signal) => signal !== 'SIGKILL'); + owned.trackChild(process); + const failure = await owned.cleanup().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AggregateError); + expect(classify(failure)).toBe(category); + const diagnostic = producer.schemaV2FailureDiagnostic( + failure, + 'phase1.stage.execution-root-cleanup.failed', + ); + expect(diagnostic).toBe(`phase1.stage.execution-root-cleanup.${category}`); + expect(publicPhase1FailureDiagnostic(new Error(diagnostic, { cause: failure }))).toBe( + diagnostic, + ); + expect(extractVerifiedRunnerDiagnostic(`phase1-conformance: ${diagnostic}`)).toBe(diagnostic); + const wrapped = producer.wrapInfrastructureFailure(new Error(diagnostic, { cause: failure }), { + status: 'failed', + }); + const outer = await runPublicPhase1StageAsync( + 'phase1.stage.schema-v2-production.failed', + async () => { + throw wrapped; + }, + ).catch((error: unknown) => error); + expect(outer).toBe(wrapped); + expect(publicPhase1FailureDiagnostic(outer)).toBe(diagnostic); + + expect(classify(failure)).not.toContain('987654'); + expect(existsSync(owned.rootPath)).toBe(true); + if (category === 'supervisor-wait') + expect(process.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM'); + Object.defineProperty(process, 'exitCode', { value: 0 }); + await owned.cleanup(); + await owned.cleanup(); + expect(existsSync(owned.rootPath)).toBe(false); + }, +); + +test('reports multiple failures while preserving reverse child cleanup order', async () => { + const owned = root(); + const order: number[] = []; + for (const pid of [987651, 987652]) { + const process = child(pid); + process.kill = vi.fn(() => { + order.push(pid); + return false; + }); + owned.trackChild(process); + } + const failure = await owned.cleanup().catch((error: unknown) => error); + expect(order).toEqual([987652, 987651]); + expect(classify(failure)).toBe('multiple'); + expect((failure as AggregateError).errors).toHaveLength(2); +}); + +test('preserves the original private native cause without accepting forged aggregates', async () => { + const owned = root(); + const process = child(); + const native = new Error('private-path-canary native-message'); + process.kill = vi.fn(() => { + throw native; + }); + owned.trackChild(process); + const failure = await owned.cleanup().catch((error: unknown) => error); + expect((failure as AggregateError).errors[0]).toBe(native); + expect(classify(failure)).toBe('child-terminate'); + expect(classify(new AggregateError([native]))).toBe('unknown'); + expect( + classify(Object.assign(new Error('child-terminate'), { category: 'child-terminate' })), + ).toBe('unknown'); +}); + +test('carries an actual owned-root precondition failure through cleanup aggregation', async () => { + const owned = root(); + rmSync(owned.rootPath, { recursive: true }); + const failure = await owned.cleanup().catch((error: unknown) => error); + expect(classify(failure)).toBe('root-precondition'); +}); + +test('classifies a replaced tracked child and leaves its root for a later cleanup', async () => { + const owned = root(); + const original = child(); + const replacement = child(); + original.kill = vi.fn(() => { + Object.defineProperty(original, 'exitCode', { value: 0 }); + owned.trackChild(replacement); + return true; + }); + owned.trackChild(original); + const failure = await owned.cleanup().catch((error: unknown) => error); + expect(classify(failure)).toBe('tracked-set-changed'); + expect(existsSync(owned.rootPath)).toBe(true); + Object.defineProperty(replacement, 'exitCode', { value: 0 }); + await owned.cleanup(); + expect(existsSync(owned.rootPath)).toBe(false); +}); + +test('does not infer a cleanup category from messages, properties, or cause chains', () => { + const diagnostic = 'phase1.stage.execution-root-cleanup.root-rename'; + const forged = Object.assign(new Error(diagnostic), { category: 'root-rename' }); + for (const error of [forged, { cause: forged }, new AggregateError([forged]), diagnostic, null]) { + expect( + producer.schemaV2FailureDiagnostic(error, 'phase1.stage.execution-root-cleanup.failed'), + ).toBe('phase1.stage.execution-root-cleanup.unknown'); + } + expect( + extractVerifiedRunnerDiagnostic( + 'phase1-conformance: phase1.stage.execution-root-cleanup.private-path-canary', + ), + ).toBeUndefined(); +});