From 64f0a13585aa1a8be5ae4403dcb6339360e59eb0 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Wed, 2 Sep 2026 16:13:25 -0600 Subject: [PATCH] fix(publication): isolate offline release builds --- .github/workflows/ci.yml | 71 +- .github/workflows/pylon-preview-release.yml | 84 +- .github/workflows/pylon-stable-release.yml | 39 +- docs/pylon-publication.md | 12 +- package-lock.json | 10 +- package.json | 3 +- ...on-prime-supported-release-recipes-v1.json | 7 + scripts/pylon-publication.test.mjs | 929 ++++++++++- scripts/run-pylon-release-sandbox.mjs | 1399 +++++++++++++++++ 9 files changed, 2438 insertions(+), 116 deletions(-) create mode 100644 scripts/run-pylon-release-sandbox.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a7f0ca187..2c37c9fa10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,6 +226,7 @@ jobs: copy: [a, b] env: PYLON_RELEASE_NODE: 22.23.2 + PYLON_RELEASE_MODE: ci PYLON_RELEASE_NPM: 11.10.1 steps: - name: Checkout exact source @@ -240,36 +241,29 @@ jobs: with: node-version: ${{ env.PYLON_RELEASE_NODE }} - - name: Install pinned build inputs + - name: Run isolated release sandbox + id: sandbox + shell: /usr/bin/bash --noprofile --norc -euo pipefail {0} run: | - sudo apt-get update - sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev - npm install --global "npm@${PYLON_RELEASE_NPM}" - test "$(node --version)" = "v${PYLON_RELEASE_NODE}" - test "$(npm --version)" = "${PYLON_RELEASE_NPM}" - npm ci - - - name: Test release contract - run: npm run test:pylon-release - - - name: Build offline and pack without network - run: | - sudo env \ - "PATH=$PATH" \ - "HOME=$HOME" \ - "GIT_CONFIG_COUNT=1" \ - "GIT_CONFIG_KEY_0=safe.directory" \ - "GIT_CONFIG_VALUE_0=$GITHUB_WORKSPACE" \ - unshare --net -- npm run release:pylon:pack - - - name: Verify artifact contract - run: npm run release:pylon:verify - - - name: Upload candidate + node_path="$(node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.execPath))')" + /usr/bin/env -i \ + "HOME=/tmp" \ + "PATH=${node_path%/*}:/usr/bin:/bin" \ + "$node_path" "$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs" \ + --task pack \ + --mode "$PYLON_RELEASE_MODE" \ + --host-os "${{ runner.os }}" \ + --workspace "$GITHUB_WORKSPACE" \ + --github-output "$GITHUB_OUTPUT" \ + --run-id "${{ github.run_id }}" \ + --run-number "${{ github.run_number }}" \ + --publication-policy-revision 3 + + - name: Upload isolated subjects uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pylon-prime-pack-${{ matrix.copy }} - path: .npm/pylon-release/artifacts + path: ${{ steps.sandbox.outputs.artifact_dir }} include-hidden-files: true if-no-files-found: error retention-days: 3 @@ -301,12 +295,16 @@ jobs: if: needs.trust.outputs.allowed == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 15 + permissions: + actions: read + contents: read strategy: fail-fast: false matrix: os: [ubuntu-24.04, macos-15] env: PYLON_RELEASE_NODE: 22.23.2 + PYLON_RELEASE_MODE: ci PYLON_RELEASE_NPM: 11.10.1 steps: - name: Checkout exact source @@ -321,17 +319,28 @@ jobs: with: node-version: ${{ env.PYLON_RELEASE_NODE }} - - name: Install pinned npm - run: npm install --global "npm@${{ env.PYLON_RELEASE_NPM }}" - - name: Download reproducible pack uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: pylon-prime-pack-a path: .npm/pylon-release/artifacts - - name: Verify temporary-prefix runtime - run: npm run release:pylon:smoke + - name: Run isolated artifact smoke + shell: /usr/bin/bash --noprofile --norc -euo pipefail {0} + run: | + node_path="$(node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.execPath))')" + /usr/bin/env -i \ + "HOME=/tmp" \ + "PATH=${node_path%/*}:/usr/bin:/bin" \ + "$node_path" "$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs" \ + --task smoke \ + --mode "$PYLON_RELEASE_MODE" \ + --host-os "${{ runner.os }}" \ + --workspace "$GITHUB_WORKSPACE" \ + --artifact-dir "$GITHUB_WORKSPACE/.npm/pylon-release/artifacts" \ + --run-id "${{ github.run_id }}" \ + --run-number "${{ github.run_number }}" \ + --publication-policy-revision 3 build-check-test: name: build-check-test diff --git a/.github/workflows/pylon-preview-release.yml b/.github/workflows/pylon-preview-release.yml index 3101b852b5..288fd9118b 100644 --- a/.github/workflows/pylon-preview-release.yml +++ b/.github/workflows/pylon-preview-release.yml @@ -50,6 +50,8 @@ jobs: needs: admission runs-on: ubuntu-24.04 timeout-minutes: 20 + env: + PYLON_RELEASE_MODE: preview permissions: contents: read strategy: @@ -57,7 +59,7 @@ jobs: matrix: copy: [a, b] steps: - - name: Checkout exact pushed source + - name: Checkout exact source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.sha }} @@ -69,41 +71,29 @@ jobs: with: node-version: ${{ env.PYLON_RELEASE_NODE }} - - name: Install pinned build inputs + - name: Run isolated release sandbox + id: sandbox + shell: /usr/bin/bash --noprofile --norc -euo pipefail {0} run: | - sudo apt-get update - sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev - npm install --global "npm@${PYLON_RELEASE_NPM}" - test "$(node --version)" = "v${PYLON_RELEASE_NODE}" - test "$(npm --version)" = "${PYLON_RELEASE_NPM}" - npm ci + node_path="$(node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.execPath))')" + /usr/bin/env -i \ + "HOME=/tmp" \ + "PATH=${node_path%/*}:/usr/bin:/bin" \ + "$node_path" "$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs" \ + --task pack \ + --mode "$PYLON_RELEASE_MODE" \ + --host-os "${{ runner.os }}" \ + --workspace "$GITHUB_WORKSPACE" \ + --github-output "$GITHUB_OUTPUT" \ + --run-id "${{ github.run_id }}" \ + --run-number "${{ github.run_number }}" \ + --publication-policy-revision 3 - - name: Test publication contract - run: | - npm run test:pylon-release - npm run test:pylon-publication - - - name: Build and pack without network - run: | - sudo env \ - "PATH=$PATH" \ - "HOME=$HOME" \ - "GIT_CONFIG_COUNT=1" \ - "GIT_CONFIG_KEY_0=safe.directory" \ - "GIT_CONFIG_VALUE_0=$GITHUB_WORKSPACE" \ - unshare --net -- npm run release:pylon:pack - - - name: Verify and prepare six exact subjects - run: | - npm run release:pylon:verify - npm run release:pylon:preview -- --publication-policy-revision 2 - npm run release:pylon:verify-preview - - - name: Upload isolated preview subjects + - name: Upload isolated subjects uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pylon-preview-pack-${{ matrix.copy }} - path: .npm/pylon-release/artifacts + path: ${{ steps.sandbox.outputs.artifact_dir }} include-hidden-files: true if-no-files-found: error retention-days: 3 @@ -183,6 +173,8 @@ jobs: needs: [pack, reproducibility] runs-on: ${{ matrix.os }} timeout-minutes: 15 + env: + PYLON_RELEASE_MODE: preview permissions: actions: read contents: read @@ -191,7 +183,7 @@ jobs: matrix: os: [ubuntu-24.04, macos-15] steps: - - name: Checkout exact pushed source + - name: Checkout exact source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.sha }} @@ -203,9 +195,6 @@ jobs: with: node-version: ${{ env.PYLON_RELEASE_NODE }} - - name: Install pinned npm - run: npm install --global "npm@${{ env.PYLON_RELEASE_NPM }}" - - name: Verify workflow artifact provenance uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: @@ -235,11 +224,22 @@ jobs: name: pylon-preview-pack-a path: .npm/pylon-release/artifacts - - name: Verify and install the exact preview bytes + - name: Run isolated artifact smoke + shell: /usr/bin/bash --noprofile --norc -euo pipefail {0} run: | - npm run release:pylon:verify-preview - node -e "require('node:fs').unlinkSync('.npm/pylon-release/artifacts/pylon-preview-channel-v1.json')" - npm run release:pylon:smoke + node_path="$(node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.execPath))')" + /usr/bin/env -i \ + "HOME=/tmp" \ + "PATH=${node_path%/*}:/usr/bin:/bin" \ + "$node_path" "$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs" \ + --task smoke \ + --mode "$PYLON_RELEASE_MODE" \ + --host-os "${{ runner.os }}" \ + --workspace "$GITHUB_WORKSPACE" \ + --artifact-dir "$GITHUB_WORKSPACE/.npm/pylon-release/artifacts" \ + --run-id "${{ github.run_id }}" \ + --run-number "${{ github.run_number }}" \ + --publication-policy-revision 3 stage-draft: name: Stage exact preview draft @@ -281,7 +281,7 @@ jobs: release.source?.commit !== context.sha || release.source?.tree !== preview.build?.source?.tree || release.build?.id !== tag || preview.build?.tag !== tag || preview.build?.releaseManifest?.sha256 !== crypto.createHash("sha256").update(releaseBytes).digest("hex") || - preview.publicationPolicyRevision !== 2 || preview.sequenceEpoch !== 1 || + preview.publicationPolicyRevision !== 3 || preview.sequenceEpoch !== 1 || preview.sequence !== Number(process.env.GITHUB_RUN_NUMBER) || preview.workflowRunId !== process.env.GITHUB_RUN_ID ) throw new Error("Preview tag plan is not bound to the exact source and workflow sequence."); core.setOutput("tag", tag); @@ -469,7 +469,7 @@ jobs: if ( release.source?.commit !== context.sha || release.source?.tree !== preview.build?.source?.tree || release.build?.id !== tag || preview.build?.tag !== tag || preview.build?.releaseManifest?.sha256 !== sha256(releaseBytes) || - preview.publicationPolicyRevision !== 2 || preview.sequenceEpoch !== 1 || + preview.publicationPolicyRevision !== 3 || preview.sequenceEpoch !== 1 || preview.sequence !== Number(process.env.GITHUB_RUN_NUMBER) || preview.workflowRunId !== process.env.GITHUB_RUN_ID ) throw new Error("Preview draft manifests do not bind the exact source and workflow sequence."); const expectedNames = [...release.assets.map((asset) => asset.file), "pylon-prime-agent-release-v1.json", "pylon-preview-channel-v1.json"].sort(); @@ -795,7 +795,7 @@ jobs: previewManifest.build.source.commit !== sourceSha || previewManifest.build.source.tree !== releaseManifest.source.tree || previewManifest.build.releaseManifest.sha256 !== sha256(releaseBytes) || - previewManifest.publicationPolicyRevision !== 2 || previewManifest.sequenceEpoch !== 1 || + previewManifest.publicationPolicyRevision !== 3 || previewManifest.sequenceEpoch !== 1 || previewManifest.sequence !== Number(process.env.GITHUB_RUN_NUMBER) || previewManifest.workflowRunId !== process.env.GITHUB_RUN_ID ) { throw new Error("Downloaded preview metadata is not bound to this exact push and workflow sequence."); diff --git a/.github/workflows/pylon-stable-release.yml b/.github/workflows/pylon-stable-release.yml index 24c7ecc78e..946c28caec 100644 --- a/.github/workflows/pylon-stable-release.yml +++ b/.github/workflows/pylon-stable-release.yml @@ -332,6 +332,8 @@ jobs: needs: [admission, verify-preview] runs-on: ${{ matrix.os }} timeout-minutes: 15 + env: + PYLON_RELEASE_MODE: historical permissions: actions: read contents: read @@ -340,7 +342,7 @@ jobs: matrix: os: [ubuntu-24.04, macos-15] steps: - - name: Checkout current protected install policy + - name: Checkout exact source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.sha }} @@ -352,9 +354,6 @@ jobs: with: node-version: ${{ env.PYLON_RELEASE_NODE }} - - name: Install pinned npm - run: npm install --global "npm@${{ env.PYLON_RELEASE_NPM }}" - - name: Verify workflow artifact provenance uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: @@ -382,10 +381,22 @@ jobs: name: verified-stable-preview path: .npm/pylon-release/artifacts - - name: Install the same exact preview bytes + - name: Run isolated artifact smoke + shell: /usr/bin/bash --noprofile --norc -euo pipefail {0} run: | - npm run release:pylon:verify-preview -- --historical - npm run release:pylon:smoke -- --historical + node_path="$(node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.execPath))')" + /usr/bin/env -i \ + "HOME=/tmp" \ + "PATH=${node_path%/*}:/usr/bin:/bin" \ + "$node_path" "$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs" \ + --task smoke \ + --mode "$PYLON_RELEASE_MODE" \ + --host-os "${{ runner.os }}" \ + --workspace "$GITHUB_WORKSPACE" \ + --artifact-dir "$GITHUB_WORKSPACE/.npm/pylon-release/artifacts" \ + --run-id "${{ github.run_id }}" \ + --run-number "${{ github.run_number }}" \ + --publication-policy-revision 3 prepare: name: Resolve exact stable transaction @@ -473,7 +484,7 @@ jobs: --operation "$OPERATION" --policy-sha "${{ github.sha }}" --policy-tree "$policy_tree" - --publication-policy-revision 2 + --publication-policy-revision 3 ) if [ "$OPERATION" = withdraw ]; then args+=(--revoke-tag "$REVOKE_STABLE_TAG" --reason "$REASON") @@ -557,7 +568,7 @@ jobs: if ( manifest.schemaVersion !== 1 || manifest.channel !== "stable" || manifest.repository !== "https://github.com/pylon-code/prime-agent" || - manifest.promotion?.publicationPolicyRevision !== 2 || + manifest.promotion?.publicationPolicyRevision !== 3 || !/^pylon-stable-[0-9]{6}-g[0-9a-f]{12}-r[1-9][0-9]*$/.test(manifest.tag) ) throw new Error("Stable manifest identity is malformed."); NODE @@ -663,8 +674,8 @@ jobs: if ( !parsed || !preview || Number(parsed[1]) !== manifest.sequence || parsed[2] !== manifest.build.source?.commit?.slice(0, 12) || Number(parsed[3]) !== manifest.build.recipeRevision || preview[1] !== parsed[2] || Number(preview[2]) !== manifest.build.recipeRevision || - manifest.build.previewTag !== manifest.build.id || ![1, 2].includes(manifest.build.publicationPolicyRevision) || - manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 2 + manifest.build.previewTag !== manifest.build.id || ![1, 2, 3].includes(manifest.build.publicationPolicyRevision) || + manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 3 ) throw new Error("Stable draft identity is malformed or not signed by this policy commit."); const name = `Pylon Prime stable ${tag}`; const encoded = bytes.toString("base64"); @@ -840,10 +851,10 @@ jobs: Number(previewMatch[2]) !== manifest.build?.recipeRevision || manifest.build.previewTag !== manifest.build.id || manifest.build.previewSequence?.sequenceEpoch !== 1 || !Number.isSafeInteger(manifest.build.previewSequence?.sequence) || manifest.build.previewSequence.sequence < 1 || !/^[1-9][0-9]*$/.test(manifest.build.previewSequence?.workflowRunId ?? "") || - manifest.build.previewTag !== process.env.PREVIEW_TAG || ![1, 2].includes(manifest.build.publicationPolicyRevision) || + manifest.build.previewTag !== process.env.PREVIEW_TAG || ![1, 2, 3].includes(manifest.build.publicationPolicyRevision) || manifest.promotion?.kind !== operation || - (mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 2 : - ![1, 2].includes(manifest.promotion?.publicationPolicyRevision)) || + (mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 3 : + ![1, 2, 3].includes(manifest.promotion?.publicationPolicyRevision)) || manifest.promotion?.policyCommit !== process.env.POLICY_SHA || manifest.promotion?.policyTree !== process.env.POLICY_TREE ) throw new Error("Stable manifest, preview recipe, operator request, or policy identity differs."); if (operation === "withdraw") { diff --git a/docs/pylon-publication.md b/docs/pylon-publication.md index 1a8ca27fb5..5def3d48a1 100644 --- a/docs/pylon-publication.md +++ b/docs/pylon-publication.md @@ -69,6 +69,16 @@ A missing protected-environment variable, omitted secret, unavailable installati `.github/workflows/pylon-preview-release.yml` runs only for an exact canonical push to `refs/heads/pylon`. It uses Node `22.23.2` and npm `11.10.1`, packs twice with build networking disabled, compares all subjects byte for byte, and installs the first pack on Ubuntu Linux and macOS. Ubuntu is the supported gate for Linux and WSL2; native Windows publication support is deferred. +Each Linux pack job invokes `scripts/run-pylon-release-sandbox.mjs` once from the untouched checkout under an empty host environment. The orchestrator validates the original HEAD, tree, and clean state with optional Git locks, hooks, and filesystem monitoring disabled. Before repository execution it creates a standalone immutable source tree with a real `.git` directory, a separate Git-archive build tree with no writable metadata, and private control storage. It downloads npm `11.10.1` from the reviewed registry URL with a four-MiB transfer bound and requires the recorded SHA-256, SHA-512, and SRI. + +Dependency installation, offline packing, contract checks, preview preparation, final verification, cache preparation, and Linux smoke use separate containers from the exact Linux/amd64 Node `22.23.2` image manifest. The orchestrator pins both the manifest digest and its independently checked image-config digest. It accepts the repository digest only as `node@…` or Docker's equivalent `docker.io/library/node@…` presentation, and explicitly requires the expected environment, entrypoint, and command while rejecting semantic volumes, healthchecks, users, workdirs, exposed ports, build hooks, shells, stop settings, and related runtime configuration. Docker versions may omit safe fields or serialize them as null or empty without weakening those semantic checks. Every run overrides the image entrypoint with exact `/usr/bin/env`, disables healthchecks, selects the exact user, workdir, command, and environment, drops every capability, sets `no-new-privileges`, and bounds PIDs, CPU, and memory. The root filesystem is read-only and temporary filesystems are private. Immutable source, Git metadata, npm, and artifact inputs are read-only. The original checkout, host home, runner control files, tokens, npmrc files, and Docker socket are never mounted or passed. Signal and normal cleanup own only exact captured cidfile identities and the one active macOS process-group identity; handlers remain installed through terminal absence proofs. Cleanup requires container removal plus a terminal inspection result and, where applicable, process-group drain. A failure prints only one enumerated public stage code, never the rejected path, value, or environment content. + +The writable build and npm-cache mounts are backed by runner storage. Docker's CPU, memory, PID, and tmpfs limits do not impose a disk-byte or inode quota on those bind mounts. Traversals that affect admission and freezing have explicit entry, depth, subject-count, or byte bounds where practical, but cleanup still has to traverse whatever private tree a failed phase left behind. Disk or inode exhaustion, a traversal-limit failure, a timeout, or incomplete cleanup fails the job before it can return or upload a publication output; this is availability protection, not a claim that bind-backed storage consumption is contained. + +Dependency-backed release and publication contract tests, in both CI and preview modes, receive only a disposable candidate copy. That copy is discarded after the container exits. A separate networkless, dependency-free preparation container is the only process allowed to write the real candidate. The candidate is then frozen. A fresh networkless, dependency-free verifier mounts it read-only and writes a canonical five- or six-file size/SHA-256 receipt to a separate control mount. After every container and process group is proven absent, the host validates that receipt and performs bounded chunked `O_NOFOLLOW` reads and writes into a never-mounted output directory. It rejects sparse files and any owner, mode, link, inode, size, timestamp, pathname, or digest change; freezes files through their descriptors to `0444` and the directory to `0555`; and repeats the validation. This copy helper is a workflow-private bridge whose preconditions are exact process absence and caller-owned private directories. It is not a general primitive for a source tree that still has a hostile concurrent writer. No repository command runs between this final freeze and upload. + +The Linux install gate first fills a disposable npm cache without lifecycle scripts, then runs the exact statically verified artifact in a fresh `--network none` container as the job's last meaningful step. The macOS gate cannot provide the Linux PID and network namespace boundary. It therefore captures the trusted setup-node executable with `fs.realpathSync.native(process.execPath)` before artifact execution, uses immutable verifier source, a fresh HOME and npm runtime, a minimal tokenless environment, and an exact process group. It sends `SIGKILL` when that group does not drain and verifies that the original group is gone. Repeated or mixed termination signals coalesce on that one captured group and the captured container set until absence proofs finish. This numeric-PGID control assumes the operating system does not reuse the PGID during the bounded interval from spawn through drain; Node does not expose a stronger macOS process-group handle. A child can deliberately escape with a new session, so this is not a claim of complete descendant containment. The statically verified artifact instead runs only in a read-only, ephemeral, terminal install job with no later publication mutation or repository validation. These gates never use mutable global npm installation. + The preview identity is: ```text @@ -86,7 +96,7 @@ The canonical preview manifest binds the full source commit/tree, artifact recip ```json { - "publicationPolicyRevision": 2, + "publicationPolicyRevision": 3, "sequenceEpoch": 1, "sequence": 123, "workflowRunId": "33428882721" diff --git a/package-lock.json b/package-lock.json index d40f4f6112..b10b7c4ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "jiti": "^2.7.0", "shx": "^0.4.0", "tsx": "^4.23.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "yaml": "2.9.0" }, "engines": { "node": ">=22.8.0" @@ -3149,7 +3150,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3851,7 +3851,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5472,7 +5471,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5549,7 +5547,6 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5670,7 +5667,6 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -5977,7 +5973,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -6030,7 +6025,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index ef0b66f742..3d862a7be2 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "jiti": "^2.7.0", "shx": "^0.4.0", "tsx": "^4.23.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "yaml": "2.9.0" }, "engines": { "node": ">=22.8.0" diff --git a/scripts/pylon-prime-supported-release-recipes-v1.json b/scripts/pylon-prime-supported-release-recipes-v1.json index 3d447aeb69..2300fe98ec 100644 --- a/scripts/pylon-prime-supported-release-recipes-v1.json +++ b/scripts/pylon-prime-supported-release-recipes-v1.json @@ -23,6 +23,13 @@ "previewWorkflowSha256": "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8", "stableWorkflowPath": ".github/workflows/pylon-stable-release.yml", "stableWorkflowSha256": "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af" + }, + { + "publicationPolicyRevision": 3, + "previewWorkflowPath": ".github/workflows/pylon-preview-release.yml", + "previewWorkflowSha256": "588802ffc11cb94b04683bd2f37fadf492e0cf3e3568abcd59320a1f25863930", + "stableWorkflowPath": ".github/workflows/pylon-stable-release.yml", + "stableWorkflowSha256": "62a61c7f77356897003e6a1cd7747e4e29210de0d921a9bc58e34ea347dbff62" } ] } diff --git a/scripts/pylon-publication.test.mjs b/scripts/pylon-publication.test.mjs index 2e977dadb9..587ebc6eb5 100644 --- a/scripts/pylon-publication.test.mjs +++ b/scripts/pylon-publication.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; import { chmodSync, closeSync, @@ -31,6 +32,7 @@ import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import { lstat as lstatFile, open as openFileHandle, readdir as readDirectoryEntries } from "node:fs/promises"; import { test } from "node:test"; +import { parse as parseYaml } from "yaml"; import { createReleaseManifest, @@ -83,6 +85,21 @@ import { PYLON_PUBLICATION_RULESET_GRAPHQL_VARIABLES, } from "./lib/pylon-ruleset-auditor.mjs"; import { validatePreviewWorkflowRunEvidence, verifyGhAttestationResult } from "./verify-pylon-publication-attestations.mjs"; +import { + assertContainerRemoved, + assertExactDockerRunArgs, + buildDockerRunArgs, + copyBoundedSubjects, + exactArtifactDirectory, + formatPublicFailure, + installSignalCleanup, + PYLON_SANDBOX_POLICY, + PYLON_SANDBOX_PUBLIC_FAIL_STAGES, + readFinalReceipt, + releaseOnlyCopy, + runDetachedProcessGroup, + validateImageInspection, +} from "./run-pylon-release-sandbox.mjs"; import { recordPreviewHighWater } from "./verify-pylon-preview-history.mjs"; import { verifyStableHistoryWithState } from "./verify-pylon-stable-history.mjs"; import { verifyPreviewPublication } from "./verify-pylon-preview-publication.mjs"; @@ -376,7 +393,7 @@ test("preview manifest binds the full source tree, build, recipe, and build-mani ]) assert.throws(() => createPreviewManifest(release, releaseBytes, invalid), /sequence identity/); for (const invalid of [ { ...invocation, publicationPolicyRevision: 0 }, - { ...invocation, publicationPolicyRevision: 3 }, + { ...invocation, publicationPolicyRevision: 4 }, { sequenceEpoch: 1, sequence: 17, workflowRunId: "33428882721" }, ]) assert.throws(() => createPreviewManifest(release, releaseBytes, invalid), /policy revision/); for (const mutate of [ @@ -3139,7 +3156,7 @@ test("stable manifest nested schema rejects extras, malformed identities, unsafe (value) => (value.build.source.tree = "abc"), (value) => (value.build.recipeRevision = 2), (value) => delete value.build.publicationPolicyRevision, - (value) => (value.build.publicationPolicyRevision = 3), + (value) => (value.build.publicationPolicyRevision = 4), (value) => (value.build.releaseManifest.file = "other.json"), (value) => (value.build.previewManifest.file = "other.json"), (value) => (value.build.assets[0].file = "../escape.tgz"), @@ -3149,7 +3166,7 @@ test("stable manifest nested schema rejects extras, malformed identities, unsafe (value) => value.build.assets.reverse(), (value) => (value.promotion.policyTree = "abc"), (value) => delete value.promotion.publicationPolicyRevision, - (value) => (value.promotion.publicationPolicyRevision = 3), + (value) => (value.promotion.publicationPolicyRevision = 4), ]) { const changed = structuredClone(stable); mutate(changed); @@ -3598,10 +3615,13 @@ test("recipe and publication policy registries close independent immutable ident const registry = parseSupportedReleaseRecipeRegistry(registryText); const policies = registry.publicationPolicies; const historicalPolicy = policies[0]; + const previousPolicy = policies[1]; const policy = policies.at(-1); - assert.deepEqual(policies.map((candidate) => candidate.publicationPolicyRevision), [1, 2]); + assert.deepEqual(policies.map((candidate) => candidate.publicationPolicyRevision), [1, 2, 3]); assert.equal(historicalPolicy.previewWorkflowSha256, "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d"); assert.equal(historicalPolicy.stableWorkflowSha256, "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a"); + assert.equal(previousPolicy.previewWorkflowSha256, "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8"); + assert.equal(previousPolicy.stableWorkflowSha256, "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af"); const preview = readFileSync(join(root, policy.previewWorkflowPath), "utf8"); const stable = readFileSync(join(root, policy.stableWorkflowPath), "utf8"); assert.deepEqual(validateApprovedWorkflowBytes( @@ -3680,11 +3700,11 @@ test("recipe and publication policy registries close independent immutable ident /Administration-gated branch-protection read/, ); - assert.match(preview, /--publication-policy-revision 2/); - assert.match(preview, /preview(?:Manifest)?\.publicationPolicyRevision !== 2/); - assert.match(stable, /--publication-policy-revision 2/); - assert.match(stable, /promotion\?\.publicationPolicyRevision !== 2/); - assert.match(stable, /!\[1, 2\]\.includes\(manifest\.build\.publicationPolicyRevision\)/); + assert.match(preview, /--publication-policy-revision 3/); + assert.match(preview, /preview(?:Manifest)?\.publicationPolicyRevision !== 3/); + assert.match(stable, /--publication-policy-revision 3/); + assert.match(stable, /promotion\?\.publicationPolicyRevision !== 3/); + assert.match(stable, /!\[1, 2, 3\]\.includes\(manifest\.build\.publicationPolicyRevision\)/); assert.throws( () => validateApprovedWorkflowBytes(historicalPolicy.previewWorkflowPath, preview, "preview", 1, policies), /bytes differ/, @@ -3693,12 +3713,12 @@ test("recipe and publication policy registries close independent immutable ident () => validateApprovedWorkflowBytes(historicalPolicy.stableWorkflowPath, stable, "stable", 1, policies), /bytes differ/, ); - const promotedByR2 = firstStable(); - promotedByR2.promotion.publicationPolicyRevision = 2; - assert.equal(validateStableManifest(promotedByR2, registry.recipes, policies), promotedByR2); - assert.equal(promotedByR2.build.recipeRevision, 1); - assert.equal(promotedByR2.build.publicationPolicyRevision, 1); - assert.equal(promotedByR2.promotion.publicationPolicyRevision, 2); + const promotedByR3 = firstStable(); + promotedByR3.promotion.publicationPolicyRevision = 3; + assert.equal(validateStableManifest(promotedByR3, registry.recipes, policies), promotedByR3); + assert.equal(promotedByR3.build.recipeRevision, 1); + assert.equal(promotedByR3.build.publicationPolicyRevision, 1); + assert.equal(promotedByR3.promotion.publicationPolicyRevision, 3); const stableVerifier = readFileSync(join(root, "scripts/verify-pylon-stable-attestation.mjs"), "utf8"); assert.match(stableVerifier, /manifest\.promotion\.publicationPolicyRevision/); assert.doesNotMatch(stableVerifier, /"stable",\s*manifest\.build\.recipeRevision/); @@ -3706,11 +3726,11 @@ test("recipe and publication policy registries close independent immutable ident assert.match(previewVerifier, /verified\.previewManifest\.publicationPolicyRevision/); for (const mutate of [ (value) => delete value.promotion.publicationPolicyRevision, - (value) => (value.promotion.publicationPolicyRevision = 3), + (value) => (value.promotion.publicationPolicyRevision = 4), (value) => (value.promotion.publicationPolicyRevision = 0), (value) => delete value.build.publicationPolicyRevision, ]) { - const changed = structuredClone(promotedByR2); + const changed = structuredClone(promotedByR3); mutate(changed); assert.throws(() => validateStableManifest(changed, registry.recipes, policies), /policy revision/); } @@ -6456,7 +6476,7 @@ test("stable stage survives a crash after createRelease by recovering its exact const oldManifest = process.env.STABLE_MANIFEST; try { const manifest = firstStable(); - manifest.promotion.publicationPolicyRevision = 2; + manifest.promotion.publicationPolicyRevision = 3; const bytes = Buffer.from(canonicalJson(manifest)); const manifestPath = join(fixture, PYLON_STABLE_MANIFEST); writeFileSync(manifestPath, bytes); @@ -6545,6 +6565,877 @@ test("final tag CAS models reject squats and preserve reservation-tag-publish or assert.ok(immutablePublish < publish.indexOf("repos.updateRelease")); }); +test("release sandbox exports a closed Docker argv policy and workflows have exact parsed schemas", async () => { + assert.equal(new Set(PYLON_SANDBOX_PUBLIC_FAIL_STAGES).size, PYLON_SANDBOX_PUBLIC_FAIL_STAGES.length); + assert.ok(PYLON_SANDBOX_PUBLIC_FAIL_STAGES.every((stage) => /^E_[A-Z_]+$/.test(stage))); + for (const stage of PYLON_SANDBOX_PUBLIC_FAIL_STAGES) { + assert.equal(formatPublicFailure(stage), `::error::Pylon release sandbox failed closed [${stage}].`); + } + assert.equal(formatPublicFailure("/private/secret"), "::error::Pylon release sandbox failed closed [E_INTERNAL]."); + const rejectedArguments = spawnSync(process.execPath, [join(root, "scripts/run-pylon-release-sandbox.mjs"), "--unsafe"], { + encoding: "utf8", + env: {}, + }); + assert.equal(rejectedArguments.status, 1); + assert.equal(rejectedArguments.stdout, ""); + assert.equal(rejectedArguments.stderr.trim(), "::error::Pylon release sandbox failed closed [E_ARGUMENTS]."); + + const fixture = (phase, mode = "preview") => ({ + cidfile: `/control/${phase}.cid`, + gid: "1001", + mode, + name: `pylon-${phase}-fixture`, + paths: phase === "contracts" ? { candidate: "/scratch/contract-candidate", dependencies: "/scratch/build/node_modules", source: "/scratch/source" } : + phase === "prepare" ? { candidate: "/scratch/real-candidate", source: "/scratch/source" } : + phase === "final" ? { candidate: "/scratch/real-candidate", control: "/control/verification", source: "/scratch/source" } : + ["smoke-cache", "smoke"].includes(phase) ? { artifacts: "/scratch/artifacts", cache: "/scratch/cache", npm: "/scratch/npm.tgz", source: "/scratch/source" } : { + build: "/scratch/build", + ...(phase === "pack" ? { dependencies: "/scratch/build/node_modules" } : {}), + git: "/scratch/source/.git", + npm: "/scratch/npm.tgz", + source: "/scratch/source", + }, + phase, + publicationPolicyRevision: 3, + runId: "123", + runNumber: "456", + uid: "1001", + }); + const valueAfter = (args, flag) => { + const indexes = args.flatMap((value, index) => value === flag ? [index] : []); + assert.equal(indexes.length, 1, `${flag} must be singleton`); + return args[indexes[0] + 1]; + }; + const expectedDestinations = { + dependencies: ["/build", "/build/.git", "/input/npm.tgz", "/source"], + pack: ["/build", "/build/.git", "/build/node_modules", "/input/npm.tgz", "/source"], + contracts: ["/candidate", "/source", "/source/node_modules"], + prepare: ["/candidate", "/source"], + final: ["/candidate", "/control", "/source"], + "smoke-cache": ["/artifacts", "/cache", "/input/npm.tgz", "/source"], + smoke: ["/artifacts", "/cache", "/input/npm.tgz", "/source"], + }; + const writableDestination = { dependencies: "/build", pack: "/build", contracts: "/candidate", prepare: "/candidate", final: "/control", "smoke-cache": "/cache", smoke: "/cache" }; + const validateArgs = (args, spec) => { + assert.equal(assertExactDockerRunArgs(args, spec), true); + assert.equal(args[0], "run"); + assert.equal(valueAfter(args, "--user"), "1001:1001"); + assert.equal(valueAfter(args, "--pids-limit"), "512"); + assert.equal(valueAfter(args, "--cpus"), "2"); + assert.equal(valueAfter(args, "--memory"), "3g"); + assert.equal(valueAfter(args, "--memory-swap"), "3g"); + assert.equal(valueAfter(args, "--cap-drop"), "ALL"); + assert.equal(valueAfter(args, "--security-opt"), "no-new-privileges"); + assert.equal(valueAfter(args, "--platform"), "linux/amd64"); + assert.equal(valueAfter(args, "--pull"), "never"); + assert.equal(valueAfter(args, "--entrypoint"), "/usr/bin/env"); + assert.equal(args.filter((value) => value === "--init").length, 1); + assert.equal(args.filter((value) => value === "--read-only").length, 1); + assert.equal(args.filter((value) => value === "--no-healthcheck").length, 1); + assert.equal(valueAfter(args, "--network"), ["dependencies", "smoke-cache"].includes(spec.phase) ? "bridge" : "none"); + assert.equal(args.filter((value) => value === "--tmpfs").length, spec.phase === "smoke" ? 3 : 2); + const imageIndex = args.indexOf(PYLON_SANDBOX_POLICY.image); + assert.ok(imageIndex > 0); + assert.equal(args[imageIndex + 1], "-i"); + assert.ok(args.slice(imageIndex + 1).includes("/usr/local/bin/node")); + assert.doesNotMatch(args.join("\n"), /(?:docker\.sock|\/original|\/runner|\/home\/runner|GITHUB_ENV|GITHUB_PATH|GITHUB_TOKEN|GH_TOKEN|BASH_ENV|NODE_OPTIONS|LD_)/); + assert.ok(!args.includes("--env")); + assert.ok(!args.includes("--rm")); + assert.ok(!args.includes("--privileged")); + assert.ok(!args.includes("--cap-add")); + assert.ok(!args.includes("--pid")); + assert.doesNotMatch(args.slice(imageIndex + 1).join("\n"), /(?:\/bin\/sh|\/usr\/bin\/bash|^sh$|^bash$)/m); + const mounts = args.flatMap((value, index) => args[index - 1] === "--mount" ? [value] : []); + const destinations = mounts.map((value) => value.match(/(?:^|,)dst=([^,]+)/)?.[1]); + assert.equal(new Set(destinations).size, destinations.length); + assert.deepEqual([...destinations].sort(), [...expectedDestinations[spec.phase]].sort()); + for (const [index, mount] of mounts.entries()) { + if (destinations[index] === writableDestination[spec.phase]) assert.doesNotMatch(mount, /,readonly$/); + else assert.match(mount, /,readonly$/); + } + }; + for (const phase of ["dependencies", "pack", "contracts", "prepare", "final", "smoke-cache", "smoke"]) { + const spec = fixture(phase); + const args = buildDockerRunArgs(spec); + validateArgs(args, spec); + const imageIndex = args.indexOf(PYLON_SANDBOX_POLICY.image); + const entrypointIndex = args.indexOf("--entrypoint"); + const healthcheckIndex = args.indexOf("--no-healthcheck"); + const mutations = [ + [...args.slice(0, imageIndex), "--network", "host", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--user", "0:0", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--pids-limit", "-1", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--cpus", "999", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--memory", "64g", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--cap-add", "ALL", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--pid", "host", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--privileged", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--mount", "type=bind,src=/original,dst=/build", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--mount", "type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock", ...args.slice(imageIndex)], + [...args.slice(0, entrypointIndex), ...args.slice(entrypointIndex + 2)], + [...args.slice(0, imageIndex), "--entrypoint", "bin/env", ...args.slice(imageIndex)], + [...args.slice(0, imageIndex), "--entrypoint", "/bin/sh", ...args.slice(imageIndex)], + [...args.slice(0, healthcheckIndex), ...args.slice(healthcheckIndex + 1)], + [...args.slice(0, imageIndex), "--no-healthcheck", ...args.slice(imageIndex)], + ["run", ...args], + [...args.slice(0, imageIndex + 1), "/bin/sh", "-c", ...args.slice(imageIndex + 1)], + ]; + for (const mutation of mutations) assert.throws(() => assertExactDockerRunArgs(mutation, spec)); + } + validateArgs(buildDockerRunArgs(fixture("smoke", "historical")), fixture("smoke", "historical")); + + const imageInspection = { + Architecture: "amd64", + Config: { + Cmd: ["node"], + Entrypoint: ["docker-entrypoint.sh"], + Env: [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "NODE_VERSION=22.23.2", + "YARN_VERSION=1.22.22", + ], + }, + Id: PYLON_SANDBOX_POLICY.imageConfigId, + Os: "linux", + RepoDigests: [PYLON_SANDBOX_POLICY.image], + }; + const githubImageInspection = { + Architecture: "amd64", + Config: { + ArgsEscaped: true, + AttachStderr: false, + AttachStdin: false, + AttachStdout: false, + Cmd: ["node"], + Domainname: "", + Entrypoint: ["docker-entrypoint.sh"], + Env: [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "NODE_VERSION=22.23.2", + "YARN_VERSION=1.22.22", + ], + ExposedPorts: null, + Healthcheck: null, + Hostname: "", + Image: "", + Labels: null, + MacAddress: "", + NetworkDisabled: false, + OnBuild: null, + OpenStdin: false, + Shell: null, + StdinOnce: false, + StopSignal: "", + StopTimeout: null, + Tty: false, + User: "", + Volumes: null, + WorkingDir: "", + }, + Id: PYLON_SANDBOX_POLICY.imageConfigId, + Os: "linux", + RepoDigests: [`docker.io/library/${PYLON_SANDBOX_POLICY.image}`], + Variant: null, + }; + const emptyImageInspection = structuredClone(githubImageInspection); + emptyImageInspection.Config.ExposedPorts = {}; + emptyImageInspection.Config.Healthcheck = {}; + emptyImageInspection.Config.OnBuild = []; + emptyImageInspection.Config.Shell = []; + emptyImageInspection.Config.Volumes = {}; + emptyImageInspection.RepoDigests.push(PYLON_SANDBOX_POLICY.image); + for (const inspection of [imageInspection, githubImageInspection, emptyImageInspection]) { + assert.equal(validateImageInspection(inspection), true); + } + for (const mutate of [ + (value) => { value.Config.Volumes = { "/data": {} }; }, + (value) => { value.Config.Healthcheck = { Test: ["CMD-SHELL", "true"] }; }, + (value) => { value.Config.User = "node"; }, + (value) => { value.Config.WorkingDir = "/app"; }, + (value) => { value.Config.ExposedPorts = { "3000/tcp": {} }; }, + (value) => { value.Config.OnBuild = ["RUN id"]; }, + (value) => { value.Config.Shell = ["/bin/sh", "-c"]; }, + (value) => { value.Config.StopSignal = "SIGTERM"; }, + (value) => { value.Config.StopTimeout = 1; }, + (value) => { value.Config.NetworkDisabled = true; }, + (value) => { value.Config.Hostname = "poison"; }, + (value) => { value.Config.Entrypoint = ["/bin/sh"]; }, + (value) => { value.Config.Cmd = ["-c", "id"]; }, + (value) => { value.Config.Env[1] = "NODE_VERSION=latest"; }, + (value) => { value.Id = `sha256:${"a".repeat(64)}`; }, + (value) => { value.Os = "darwin"; }, + (value) => { value.Architecture = "arm64"; }, + (value) => { value.Variant = "v8"; }, + (value) => { value.RepoDigests = []; }, + (value) => { value.RepoDigests = [`evil.example/node@${PYLON_SANDBOX_POLICY.image.split("@")[1]}`]; }, + (value) => { value.RepoDigests.push(value.RepoDigests[0]); }, + (value) => { value.Config = null; }, + ]) { + const changed = structuredClone(githubImageInspection); + mutate(changed); + assert.throws(() => validateImageInspection(changed)); + } + + const containerId = "b".repeat(64); + const removal = { error: undefined, signal: null, status: 0, stdout: `${containerId}\n` }; + const inspection = { error: undefined, signal: null, status: 1, stderr: `Error response from daemon: No such container: ${containerId}\n` }; + assert.equal(assertContainerRemoved(containerId, removal, inspection), true); + for (const [changedRemoval, changedInspection] of [ + [{ ...removal, status: 1 }, inspection], + [{ ...removal, stdout: "" }, inspection], + [removal, { ...inspection, status: 0 }], + [removal, { ...inspection, status: null, signal: "SIGTERM" }], + [removal, { ...inspection, error: new Error("daemon unavailable") }], + [removal, { ...inspection, stderr: "daemon unavailable" }], + ]) assert.throws(() => assertContainerRemoved(containerId, changedRemoval, changedInspection)); + + const packageManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const packageLock = JSON.parse(readFileSync(join(root, "package-lock.json"), "utf8")); + assert.equal(packageManifest.devDependencies.yaml, "2.9.0"); + assert.equal(packageLock.packages[""].devDependencies.yaml, "2.9.0"); + + const expectedJobs = { + "ciPack": { + "name": "Pylon artifact pack (${{ matrix.copy }})", + "needs": "trust", + "if": "needs.trust.outputs.allowed == 'true'", + "runs-on": "ubuntu-24.04", + "timeout-minutes": 20, + "strategy": { + "fail-fast": false, + "matrix": { + "copy": [ + "a", + "b" + ] + } + }, + "env": { + "PYLON_RELEASE_NODE": "22.23.2", + "PYLON_RELEASE_MODE": "ci", + "PYLON_RELEASE_NPM": "11.10.1" + }, + "steps": [ + { + "name": "Checkout exact source", + "uses": "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "with": { + "ref": "${{ inputs.checkout_ref || github.ref }}", + "persist-credentials": false, + "fetch-depth": 1 + } + }, + { + "name": "Setup pinned Node.js", + "uses": "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + "with": { + "node-version": "${{ env.PYLON_RELEASE_NODE }}" + } + }, + { + "name": "Run isolated release sandbox", + "id": "sandbox", + "shell": "/usr/bin/bash --noprofile --norc -euo pipefail {0}", + "run": "node_path=\"$(node -e 'process.stdout.write(require(\"node:fs\").realpathSync.native(process.execPath))')\"\n/usr/bin/env -i \\\n \"HOME=/tmp\" \\\n \"PATH=${node_path%/*}:/usr/bin:/bin\" \\\n \"$node_path\" \"$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs\" \\\n --task pack \\\n --mode \"$PYLON_RELEASE_MODE\" \\\n --host-os \"${{ runner.os }}\" \\\n --workspace \"$GITHUB_WORKSPACE\" \\\n --github-output \"$GITHUB_OUTPUT\" \\\n --run-id \"${{ github.run_id }}\" \\\n --run-number \"${{ github.run_number }}\" \\\n --publication-policy-revision 3\n" + }, + { + "name": "Upload isolated subjects", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "with": { + "name": "pylon-prime-pack-${{ matrix.copy }}", + "path": "${{ steps.sandbox.outputs.artifact_dir }}", + "include-hidden-files": true, + "if-no-files-found": "error", + "retention-days": 3 + } + } + ] + }, + "ciInstall": { + "name": "Pylon artifact install (${{ matrix.os }})", + "needs": [ + "trust", + "pylon-artifact-pack", + "pylon-artifact-reproducibility" + ], + "if": "needs.trust.outputs.allowed == 'true'", + "runs-on": "${{ matrix.os }}", + "timeout-minutes": 15, + "permissions": { + "actions": "read", + "contents": "read" + }, + "strategy": { + "fail-fast": false, + "matrix": { + "os": [ + "ubuntu-24.04", + "macos-15" + ] + } + }, + "env": { + "PYLON_RELEASE_NODE": "22.23.2", + "PYLON_RELEASE_MODE": "ci", + "PYLON_RELEASE_NPM": "11.10.1" + }, + "steps": [ + { + "name": "Checkout exact source", + "uses": "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "with": { + "ref": "${{ inputs.checkout_ref || github.ref }}", + "persist-credentials": false, + "fetch-depth": 1 + } + }, + { + "name": "Setup pinned Node.js", + "uses": "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + "with": { + "node-version": "${{ env.PYLON_RELEASE_NODE }}" + } + }, + { + "name": "Download reproducible pack", + "uses": "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "with": { + "name": "pylon-prime-pack-a", + "path": ".npm/pylon-release/artifacts" + } + }, + { + "name": "Run isolated artifact smoke", + "shell": "/usr/bin/bash --noprofile --norc -euo pipefail {0}", + "run": "node_path=\"$(node -e 'process.stdout.write(require(\"node:fs\").realpathSync.native(process.execPath))')\"\n/usr/bin/env -i \\\n \"HOME=/tmp\" \\\n \"PATH=${node_path%/*}:/usr/bin:/bin\" \\\n \"$node_path\" \"$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs\" \\\n --task smoke \\\n --mode \"$PYLON_RELEASE_MODE\" \\\n --host-os \"${{ runner.os }}\" \\\n --workspace \"$GITHUB_WORKSPACE\" \\\n --artifact-dir \"$GITHUB_WORKSPACE/.npm/pylon-release/artifacts\" \\\n --run-id \"${{ github.run_id }}\" \\\n --run-number \"${{ github.run_number }}\" \\\n --publication-policy-revision 3\n" + } + ] + }, + "previewPack": { + "name": "Preview offline pack (${{ matrix.copy }})", + "needs": "admission", + "runs-on": "ubuntu-24.04", + "timeout-minutes": 20, + "env": { + "PYLON_RELEASE_MODE": "preview" + }, + "permissions": { + "contents": "read" + }, + "strategy": { + "fail-fast": false, + "matrix": { + "copy": [ + "a", + "b" + ] + } + }, + "steps": [ + { + "name": "Checkout exact source", + "uses": "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "with": { + "ref": "${{ github.sha }}", + "persist-credentials": false, + "fetch-depth": 1 + } + }, + { + "name": "Setup pinned Node.js", + "uses": "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + "with": { + "node-version": "${{ env.PYLON_RELEASE_NODE }}" + } + }, + { + "name": "Run isolated release sandbox", + "id": "sandbox", + "shell": "/usr/bin/bash --noprofile --norc -euo pipefail {0}", + "run": "node_path=\"$(node -e 'process.stdout.write(require(\"node:fs\").realpathSync.native(process.execPath))')\"\n/usr/bin/env -i \\\n \"HOME=/tmp\" \\\n \"PATH=${node_path%/*}:/usr/bin:/bin\" \\\n \"$node_path\" \"$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs\" \\\n --task pack \\\n --mode \"$PYLON_RELEASE_MODE\" \\\n --host-os \"${{ runner.os }}\" \\\n --workspace \"$GITHUB_WORKSPACE\" \\\n --github-output \"$GITHUB_OUTPUT\" \\\n --run-id \"${{ github.run_id }}\" \\\n --run-number \"${{ github.run_number }}\" \\\n --publication-policy-revision 3\n" + }, + { + "name": "Upload isolated subjects", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "with": { + "name": "pylon-preview-pack-${{ matrix.copy }}", + "path": "${{ steps.sandbox.outputs.artifact_dir }}", + "include-hidden-files": true, + "if-no-files-found": "error", + "retention-days": 3 + } + } + ] + }, + "previewInstall": { + "name": "Preview installed artifact (${{ matrix.os }})", + "needs": [ + "pack", + "reproducibility" + ], + "runs-on": "${{ matrix.os }}", + "timeout-minutes": 15, + "env": { + "PYLON_RELEASE_MODE": "preview" + }, + "permissions": { + "actions": "read", + "contents": "read" + }, + "strategy": { + "fail-fast": false, + "matrix": { + "os": [ + "ubuntu-24.04", + "macos-15" + ] + } + }, + "steps": [ + { + "name": "Checkout exact source", + "uses": "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "with": { + "ref": "${{ github.sha }}", + "persist-credentials": false, + "fetch-depth": 1 + } + }, + { + "name": "Setup pinned Node.js", + "uses": "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + "with": { + "node-version": "${{ env.PYLON_RELEASE_NODE }}" + } + }, + { + "name": "Verify workflow artifact provenance", + "uses": "actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea", + "with": { + "script": "const run = (await github.rest.actions.getWorkflowRun({ ...context.repo, run_id: context.runId })).data;\nconst workflow = (await github.rest.actions.getWorkflow({ ...context.repo, workflow_id: run.workflow_id })).data;\nif (\n context.repo.owner !== \"pylon-code\" || context.repo.repo !== \"prime-agent\" ||\n run.id !== context.runId || run.run_number !== Number(process.env.GITHUB_RUN_NUMBER) ||\n String(run.run_attempt) !== process.env.GITHUB_RUN_ATTEMPT || run.repository?.id !== 1349002285 ||\n run.repository?.full_name !== \"pylon-code/prime-agent\" || run.head_repository?.id !== 1349002285 ||\n run.head_repository?.full_name !== \"pylon-code/prime-agent\" || run.event !== context.eventName ||\n run.head_sha !== context.sha || run.head_branch !== \"pylon\" || context.ref !== \"refs/heads/pylon\" ||\n workflow.path !== \".github/workflows/pylon-preview-release.yml\"\n) throw new Error(\"Artifact workflow provenance is not canonical.\");\nconst artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {\n ...context.repo, run_id: context.runId, per_page: 100,\n});\nconst matches = artifacts.filter((artifact) => artifact.name === \"pylon-preview-pack-a\");\nif (matches.length !== 1 || matches[0].expired || !/^sha256:[0-9a-f]{64}$/.test(matches[0].digest ?? \"\")) {\n throw new Error(\"Artifact is ambiguous, expired, or lacks a SHA-256 transport digest.\");\n}\n" + } + }, + { + "name": "Download byte-identical preview subjects", + "uses": "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "with": { + "name": "pylon-preview-pack-a", + "path": ".npm/pylon-release/artifacts" + } + }, + { + "name": "Run isolated artifact smoke", + "shell": "/usr/bin/bash --noprofile --norc -euo pipefail {0}", + "run": "node_path=\"$(node -e 'process.stdout.write(require(\"node:fs\").realpathSync.native(process.execPath))')\"\n/usr/bin/env -i \\\n \"HOME=/tmp\" \\\n \"PATH=${node_path%/*}:/usr/bin:/bin\" \\\n \"$node_path\" \"$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs\" \\\n --task smoke \\\n --mode \"$PYLON_RELEASE_MODE\" \\\n --host-os \"${{ runner.os }}\" \\\n --workspace \"$GITHUB_WORKSPACE\" \\\n --artifact-dir \"$GITHUB_WORKSPACE/.npm/pylon-release/artifacts\" \\\n --run-id \"${{ github.run_id }}\" \\\n --run-number \"${{ github.run_number }}\" \\\n --publication-policy-revision 3\n" + } + ] + }, + "stableInstall": { + "name": "Stable candidate installed artifact (${{ matrix.os }})", + "needs": [ + "admission", + "verify-preview" + ], + "runs-on": "${{ matrix.os }}", + "timeout-minutes": 15, + "env": { + "PYLON_RELEASE_MODE": "historical" + }, + "permissions": { + "actions": "read", + "contents": "read" + }, + "strategy": { + "fail-fast": false, + "matrix": { + "os": [ + "ubuntu-24.04", + "macos-15" + ] + } + }, + "steps": [ + { + "name": "Checkout exact source", + "uses": "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "with": { + "ref": "${{ github.sha }}", + "persist-credentials": false, + "fetch-depth": 1 + } + }, + { + "name": "Setup pinned Node.js", + "uses": "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + "with": { + "node-version": "${{ env.PYLON_RELEASE_NODE }}" + } + }, + { + "name": "Verify workflow artifact provenance", + "uses": "actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea", + "with": { + "script": "const run = (await github.rest.actions.getWorkflowRun({ ...context.repo, run_id: context.runId })).data;\nconst workflow = (await github.rest.actions.getWorkflow({ ...context.repo, workflow_id: run.workflow_id })).data;\nif (\n context.repo.owner !== \"pylon-code\" || context.repo.repo !== \"prime-agent\" || run.repository?.id !== 1349002285 ||\n run.repository?.full_name !== \"pylon-code/prime-agent\" || run.head_repository?.id !== 1349002285 ||\n run.head_repository?.full_name !== \"pylon-code/prime-agent\" || run.event !== context.eventName ||\n run.head_sha !== context.sha || run.head_branch !== \"pylon\" || context.ref !== \"refs/heads/pylon\" ||\n workflow.path !== \".github/workflows/pylon-stable-release.yml\"\n) throw new Error(\"Artifact workflow provenance is not canonical.\");\nconst artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {\n ...context.repo, run_id: context.runId, per_page: 100,\n});\nconst matches = artifacts.filter((artifact) => artifact.name === \"verified-stable-preview\");\nif (matches.length !== 1 || matches[0].expired || !/^sha256:[0-9a-f]{64}$/.test(matches[0].digest ?? \"\")) {\n throw new Error(\"Artifact is ambiguous, expired, or lacks a SHA-256 transport digest.\");\n}\n" + } + }, + { + "name": "Download verified preview bytes", + "uses": "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "with": { + "name": "verified-stable-preview", + "path": ".npm/pylon-release/artifacts" + } + }, + { + "name": "Run isolated artifact smoke", + "shell": "/usr/bin/bash --noprofile --norc -euo pipefail {0}", + "run": "node_path=\"$(node -e 'process.stdout.write(require(\"node:fs\").realpathSync.native(process.execPath))')\"\n/usr/bin/env -i \\\n \"HOME=/tmp\" \\\n \"PATH=${node_path%/*}:/usr/bin:/bin\" \\\n \"$node_path\" \"$GITHUB_WORKSPACE/scripts/run-pylon-release-sandbox.mjs\" \\\n --task smoke \\\n --mode \"$PYLON_RELEASE_MODE\" \\\n --host-os \"${{ runner.os }}\" \\\n --workspace \"$GITHUB_WORKSPACE\" \\\n --artifact-dir \"$GITHUB_WORKSPACE/.npm/pylon-release/artifacts\" \\\n --run-id \"${{ github.run_id }}\" \\\n --run-number \"${{ github.run_number }}\" \\\n --publication-policy-revision 3\n" + } + ] + } +}; + const expectedWorkflowRoots = { + ci: { + env: undefined, + keys: ["concurrency", "jobs", "name", "on", "permissions"], + }, + preview: { + env: { + PYLON_RELEASE_NODE: "22.23.2", + PYLON_RELEASE_NPM: "11.10.1", + }, + keys: ["concurrency", "env", "jobs", "name", "on", "permissions"], + }, + stable: { + env: { + PYLON_RELEASE_NODE: "22.23.2", + PYLON_RELEASE_NPM: "11.10.1", + }, + keys: ["concurrency", "env", "jobs", "name", "on", "permissions"], + }, + }; + const parseExactWorkflowRoot = (workflow, expected) => { + const parsed = parseYaml(workflow, { merge: false, uniqueKeys: true }); + assert.ok(parsed && typeof parsed === "object" && !Array.isArray(parsed)); + assert.deepEqual(Object.keys(parsed).sort(), expected.keys); + assert.deepEqual(parsed.env, expected.env); + assert.ok(parsed.jobs && typeof parsed.jobs === "object" && !Array.isArray(parsed.jobs)); + return parsed; + }; + const preview = readFileSync(join(root, ".github/workflows/pylon-preview-release.yml"), "utf8"); + const ci = readFileSync(join(root, ".github/workflows/ci.yml"), "utf8"); + const stable = readFileSync(join(root, ".github/workflows/pylon-stable-release.yml"), "utf8"); + const assertExactWorkflows = (ciWorkflow, previewWorkflow, stableWorkflow) => { + const parsedCi = parseExactWorkflowRoot(ciWorkflow, expectedWorkflowRoots.ci); + const parsedPreview = parseExactWorkflowRoot(previewWorkflow, expectedWorkflowRoots.preview); + const parsedStable = parseExactWorkflowRoot(stableWorkflow, expectedWorkflowRoots.stable); + assert.deepEqual(parsedCi.jobs["pylon-artifact-pack"], expectedJobs.ciPack); + assert.deepEqual(parsedCi.jobs["pylon-artifact-install"], expectedJobs.ciInstall); + assert.deepEqual(parsedPreview.jobs.pack, expectedJobs.previewPack); + assert.deepEqual(parsedPreview.jobs.install, expectedJobs.previewInstall); + assert.deepEqual(parsedStable.jobs.install, expectedJobs.stableInstall); + }; + assertExactWorkflows(ci, preview, stable); + assert.doesNotMatch(`${preview}\n${ci}\n${stable}`, /(?:npm install --global|\/usr\/bin\/realpath|\/usr\/bin\/dirname)/); + for (const job of [expectedJobs.ciPack, expectedJobs.ciInstall, expectedJobs.previewPack, expectedJobs.previewInstall, expectedJobs.stableInstall]) { + const sandbox = job.steps.find((step) => step.name.startsWith("Run isolated")); + assert.match(sandbox.run, /^node_path="\$\(node -e 'process\.stdout\.write\(require\("node:fs"\)\.realpathSync\.native\(process\.execPath\)\)'\)"/); + assert.match(sandbox.run, /"PATH=\$\{node_path%\/\*\}:\/usr\/bin:\/bin"/); + } + const capture = spawnSync(process.execPath, ["-e", 'process.stdout.write(require("node:fs").realpathSync.native(process.execPath))'], { encoding: "utf8", env: {} }); + assert.equal(capture.status, 0); + assert.equal(capture.stdout, realpathSync.native(process.execPath)); + + const workflowMutations = [ + [ci.replace("permissions:\n contents: read", "arbitrary-root-policy: unsafe\n\npermissions:\n contents: read"), preview, stable], + [ci, preview.replace(" PYLON_RELEASE_NPM: 11.10.1", " PYLON_RELEASE_NPM: 11.10.1\n BASH_ENV: /tmp/poison"), stable], + [ci, preview.replace(" PYLON_RELEASE_NPM: 11.10.1", " PYLON_RELEASE_NPM: 11.10.1\n NODE_OPTIONS: --require=/tmp/poison.cjs"), stable], + [ci, preview.replace(" PYLON_RELEASE_NPM: 11.10.1", " PYLON_RELEASE_NPM: 11.10.1\n GITHUB_PATH: /tmp/poison"), stable], + [ci, preview.replace(" PYLON_RELEASE_NODE: 22.23.2", " PYLON_RELEASE_NODE: latest"), stable], + [ci.replace(" name: Pylon artifact pack", " unknown-policy: true\n name: Pylon artifact pack"), preview, stable], + [ci.replace(" PYLON_RELEASE_MODE: ci", " PYLON_RELEASE_MODE: ci\n POISON: yes"), preview, stable], + [ci.replace(" id: sandbox", " id: sandbox\n env:\n POISON: yes"), preview, stable], + [ci.replace(" - name: Upload isolated subjects", " - name: Extra host command\n run: echo unsafe\n\n - name: Upload isolated subjects"), preview, stable], + [ci.replace(" id: sandbox", " id: sandbox\n run: echo duplicate"), preview, stable], + [ci.replace(" id: sandbox", " id: sandbox\n mystery: unsafe"), preview, stable], + [ci.replace(" node_path=", " echo unsafe\n node_path="), preview, stable], + [ci.replace(" --publication-policy-revision 3", " --publication-policy-revision 3\n echo unsafe"), preview, stable], + [ci.replace(" /usr/bin/env -i", " sudo /usr/bin/env -i"), preview, stable], + ]; + for (const [changedCi, changedPreview, changedStable] of workflowMutations) { + assert.throws(() => assertExactWorkflows(changedCi, changedPreview, changedStable)); + } + + const exerciseRepeatedSignal = async (primarySignal, repeatedSignals) => { + const signalTarget = new EventEmitter(); + const signaledState = { + activePgid: null, + containers: [{ id: "d".repeat(64) }], + control: "/private/control", + scratch: "/private/scratch", + }; + const signalCleanup = []; + let cleanupCalls = 0; + let exitCalls = 0; + let capturedPgid; + let resolveSignalExit; + const signalExit = new Promise((resolveExit) => { resolveSignalExit = resolveExit; }); + const removeSignalCleanup = installSignalCleanup(signaledState, { + cleanupContainers: (state) => { + cleanupCalls += 1; + assert.equal(state.containers.length, 1); + state.containers.length = 0; + signalCleanup.push("containers-absent"); + return true; + }, + exit: (code) => { + exitCalls += 1; + assert.equal(signaledState.activePgid, null); + assert.equal(signaledState.containers.length, 0); + assert.throws(() => process.kill(-capturedPgid, 0), (error) => error?.code === "ESRCH"); + resolveSignalExit(code); + }, + removeOwnedTree: (path) => signalCleanup.push(`removed:${path}`), + signalTarget, + }); + const interruptedMacSmoke = runDetachedProcessGroup(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + cwd: root, + env: {}, + lifecycle: signaledState, + timeoutMs: 30_000, + }); + const interruptedMacSmokeRejection = assert.rejects(interruptedMacSmoke); + capturedPgid = signaledState.activePgid; + assert.ok(Number.isSafeInteger(capturedPgid) && capturedPgid > 0); + signalTarget.emit(primarySignal); + assert.equal(signalTarget.listenerCount("SIGINT"), 1); + assert.equal(signalTarget.listenerCount("SIGTERM"), 1); + for (const repeatedSignal of repeatedSignals) signalTarget.emit(repeatedSignal); + assert.equal(signalTarget.listenerCount("SIGINT"), 1); + assert.equal(signalTarget.listenerCount("SIGTERM"), 1); + const exitCode = await signalExit; + await interruptedMacSmokeRejection; + assert.equal(signaledState.activePgid, null); + assert.equal(cleanupCalls, 1); + assert.equal(exitCalls, 1); + assert.deepEqual(signalCleanup, ["containers-absent", "removed:/private/scratch", "removed:/private/control"]); + assert.equal(signalTarget.listenerCount("SIGINT"), 0); + assert.equal(signalTarget.listenerCount("SIGTERM"), 0); + await removeSignalCleanup(); + return exitCode; + }; + assert.equal(await exerciseRepeatedSignal("SIGTERM", ["SIGTERM", "SIGINT"]), 143); + assert.equal(await exerciseRepeatedSignal("SIGINT", ["SIGINT"]), 130); + + const cleanupBoundaryTarget = new EventEmitter(); + const cleanupBoundaryState = { + activePgid: null, + containers: [{ id: "c".repeat(64) }], + control: "/private/final-control", + scratch: "/private/final-scratch", + }; + let absenceProved = false; + let resolveBoundaryExit; + const boundaryExit = new Promise((resolveExit) => { resolveBoundaryExit = resolveExit; }); + const removeBoundaryCleanup = installSignalCleanup(cleanupBoundaryState, { + cleanupContainers: (state) => { + assert.equal(state.containers.length, 1); + state.containers.length = 0; + absenceProved = true; + return true; + }, + exit: (code) => { + assert.equal(absenceProved, true); + resolveBoundaryExit(code); + }, + removeOwnedTree: () => {}, + signalTarget: cleanupBoundaryTarget, + }); + const finalHandlerRelease = removeBoundaryCleanup(); + queueMicrotask(() => { + cleanupBoundaryTarget.emit("SIGINT"); + assert.equal(cleanupBoundaryTarget.listenerCount("SIGINT"), 1); + assert.equal(cleanupBoundaryTarget.listenerCount("SIGTERM"), 1); + cleanupBoundaryTarget.emit("SIGINT"); + cleanupBoundaryTarget.emit("SIGTERM"); + }); + await finalHandlerRelease; + assert.equal(await boundaryExit, 130); + assert.equal(cleanupBoundaryState.containers.length, 0); + assert.equal(cleanupBoundaryTarget.listenerCount("SIGINT"), 0); + assert.equal(cleanupBoundaryTarget.listenerCount("SIGTERM"), 0); + + const processReceipt = await runDetachedProcessGroup(process.execPath, ["-e", "process.exit(0)"], { cwd: root, env: {}, timeoutMs: 30_000 }); + assert.ok(Number.isSafeInteger(processReceipt.pid) && processReceipt.pid > 0); + assert.equal(processReceipt.status, 0); + assert.equal(processReceipt.signal, null); + await assert.rejects(runDetachedProcessGroup(process.execPath, ["-e", ` +const { spawn } = require("node:child_process"); +const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); +child.unref(); +`], { cwd: root, env: {}, timeoutMs: 30_000 })); +}); + +test("bounded final subject copy binds receipt, metadata, sparse allocation, and disposable watcher isolation", async () => { + const makeSource = () => { + const parent = realpathSync.native(mkdtempSync(join(tmpdir(), "pylon-final-copy-"))); + const source = join(parent, "source"); + mkdirSync(source, { mode: 0o700 }); + for (let index = 0; index < 5; index += 1) { + const file = join(source, `subject-${index}.tgz`); + writeFileSync(file, `subject-${index}`, { mode: 0o644 }); + chmodSync(file, 0o644); + } + return { parent, source, destination: join(parent, "final") }; + }; + const clean = (parent) => { + for (const directory of [join(parent, "historical"), join(parent, "preview"), join(parent, "disposable"), join(parent, "final"), join(parent, "source"), parent]) { + try { chmodSync(directory, 0o700); } catch {} + } + rmSync(parent, { force: true, recursive: true }); + }; + const canonicalReceiptBytes = (files) => Buffer.from(`${JSON.stringify({ files, schemaVersion: 1 })}\n`); + const writeReceipt = (path, files) => { + writeFileSync(path, canonicalReceiptBytes(files), { flag: "wx", mode: 0o644 }); + chmodSync(path, 0o644); + }; + { + const fixture = makeSource(); + try { + const initial = copyBoundedSubjects(fixture.source, fixture.destination, { expectedCount: 5, freeze: true, inputGid: null }); + assert.equal(initial.names.length, 5); + assert.equal(lstatSync(fixture.destination).mode & 0o7777, 0o555); + for (const name of initial.names) { + const metadata = lstatSync(join(fixture.destination, name)); + assert.equal(metadata.mode & 0o7777, 0o444); + assert.equal(metadata.nlink, 1); + } + } finally { clean(fixture.parent); } + } + const mutations = [ + ({ source }) => { chmodSync(join(source, "subject-0.tgz"), 0o666); }, + ({ source }) => { rmSync(join(source, "subject-0.tgz")); symlinkSync("subject-1.tgz", join(source, "subject-0.tgz")); }, + ({ source }) => { rmSync(join(source, "subject-0.tgz")); linkSync(join(source, "subject-1.tgz"), join(source, "subject-0.tgz")); }, + ({ source }) => { truncateSync(join(source, "subject-0.tgz"), 1024 * 1024); }, + ({ source }) => { + rmSync(join(source, "subject-0.tgz")); + const result = spawnSync("/usr/bin/mkfifo", [join(source, "subject-0.tgz")], { stdio: "pipe" }); + assert.equal(result.status, 0); + }, + ({ source }) => { writeFileSync(join(source, "extra.tgz"), "extra", { mode: 0o644 }); }, + ]; + for (const mutate of mutations) { + const fixture = makeSource(); + try { + mutate(fixture); + assert.throws(() => copyBoundedSubjects(fixture.source, fixture.destination, { expectedCount: 5, freeze: true })); + assert.equal(existsSync(fixture.destination), false); + } finally { clean(fixture.parent); } + } + { + const fixture = makeSource(); + const linked = join(fixture.parent, "linked-source"); + try { + symlinkSync(fixture.source, linked); + assert.throws(() => exactArtifactDirectory(linked)); + assert.throws(() => copyBoundedSubjects(linked, fixture.destination, { expectedCount: 5, freeze: true })); + } finally { clean(fixture.parent); } + } + { + const fixture = makeSource(); + try { + const probe = copyBoundedSubjects(fixture.source, join(fixture.parent, "probe"), { expectedCount: 5 }); + const receiptPath = join(fixture.parent, "receipt.json"); + writeReceipt(receiptPath, probe.files); + const receipt = readFinalReceipt(receiptPath, 5); + copyBoundedSubjects(fixture.source, fixture.destination, { expectedCount: 5, expectedReceipt: receipt, freeze: true }); + assert.throws(() => readFinalReceipt(join(fixture.parent, "missing-receipt.json"), 5)); + const wrongPath = join(fixture.parent, "wrong.json"); + const wrong = structuredClone(probe.files); + wrong[0].sha256 = "f".repeat(64); + writeReceipt(wrongPath, wrong); + const wrongReceipt = readFinalReceipt(wrongPath, 5); + assert.throws(() => copyBoundedSubjects(fixture.source, join(fixture.parent, "wrong-final"), { expectedCount: 5, expectedReceipt: wrongReceipt, freeze: true })); + writeFileSync(join(fixture.source, "subject-0.tgz"), "stale-source-bytes"); + assert.throws(() => copyBoundedSubjects(fixture.source, join(fixture.parent, "stale-final"), { expectedCount: 5, expectedReceipt: receipt, freeze: true })); + const unsorted = join(fixture.parent, "unsorted.json"); + writeReceipt(unsorted, [...probe.files].reverse()); + assert.throws(() => readFinalReceipt(unsorted, 5)); + const noncanonical = join(fixture.parent, "noncanonical.json"); + writeFileSync(noncanonical, `${JSON.stringify({ schemaVersion: 1, files: probe.files }, null, 2)}\n`, { mode: 0o644 }); + chmodSync(noncanonical, 0o644); + assert.throws(() => readFinalReceipt(noncanonical, 5)); + } finally { clean(fixture.parent); } + } + { + const fixture = makeSource(); + let watcher; + try { + const receipt = copyBoundedSubjects(fixture.source, join(fixture.parent, "probe"), { expectedCount: 5 }); + const disposable = join(fixture.parent, "disposable"); + copyBoundedSubjects(fixture.source, disposable, { expectedCount: 5 }); + const properLockfileWatcher = ` +const { writeFileSync } = require("node:fs"); +const target = process.argv[1]; +let running = true; +process.stdin.once("data", () => { running = false; }); +process.stdout.write("ready\\n"); +const mutate = () => { + writeFileSync(target, "malicious-proper-lockfile-watcher"); + if (running) setImmediate(mutate); + else process.exit(0); +}; +mutate();`; + watcher = spawn(process.execPath, ["-e", properLockfileWatcher, join(disposable, "subject-0.tgz")], { stdio: ["pipe", "pipe", "inherit"] }); + await new Promise((resolveReady, rejectReady) => { + const timer = setTimeout(() => rejectReady(new Error("watcher readiness timed out")), 10_000); + watcher.once("error", rejectReady); + watcher.stdout.once("data", (chunk) => { + clearTimeout(timer); + if (!chunk.toString("utf8").includes("ready")) rejectReady(new Error("watcher readiness malformed")); + else resolveReady(); + }); + }); + copyBoundedSubjects(fixture.source, fixture.destination, { expectedCount: 5, expectedReceipt: { files: receipt.files, schemaVersion: 1 }, freeze: true }); + watcher.stdin.end("stop"); + await new Promise((resolveClose, rejectClose) => { + const timer = setTimeout(() => rejectClose(new Error("watcher exit timed out")), 10_000); + watcher.once("error", rejectClose); + watcher.once("close", (status) => { + clearTimeout(timer); + if (status !== 0) rejectClose(new Error("watcher failed")); + else resolveClose(); + }); + }); + watcher = undefined; + assert.equal(readFileSync(join(fixture.source, "subject-0.tgz"), "utf8"), "subject-0"); + assert.equal(readFileSync(join(fixture.destination, "subject-0.tgz"), "utf8"), "subject-0"); + assert.equal(readFileSync(join(disposable, "subject-0.tgz"), "utf8"), "malicious-proper-lockfile-watcher"); + } finally { + if (watcher && watcher.exitCode === null) watcher.kill("SIGKILL"); + clean(fixture.parent); + } + } + { + const parent = realpathSync.native(mkdtempSync(join(tmpdir(), "pylon-historical-copy-"))); + const source = join(parent, "source"); + mkdirSync(source, { mode: 0o700 }); + const assets = ["pylon-prime-agent-0.8.1.tgz", "pylon-prime-agent-ai-0.8.1.tgz", "pylon-prime-agent-core-0.8.1.tgz", "pylon-prime-agent-tui-0.8.1.tgz"]; + for (const name of assets) writeFileSync(join(source, name), name, { mode: 0o444 }); + writeFileSync(join(source, "pylon-prime-agent-release-v1.json"), `${JSON.stringify({ assets: assets.map((file) => ({ file })) })}\n`, { mode: 0o444 }); + writeFileSync(join(source, "pylon-preview-channel-v1.json"), "channel\n", { mode: 0o444 }); + for (const name of readdirSync(source)) chmodSync(join(source, name), 0o444); + try { + const preview = releaseOnlyCopy(source, join(parent, "preview"), { historical: false }); + assert.equal(preview.names.length, 5); + assert.equal(preview.names.includes("pylon-preview-channel-v1.json"), false); + const historical = releaseOnlyCopy(source, join(parent, "historical"), { historical: true }); + assert.equal(historical.names.length, 6); + assert.equal(historical.names.includes("pylon-preview-channel-v1.json"), true); + } finally { clean(parent); } + } +}); + test("workflow static policy proves direct approvals and every contents-write graph", () => { const workflowFiles = readdirSync(join(root, ".github/workflows")) .filter((file) => /\.ya?ml$/.test(file)) diff --git a/scripts/run-pylon-release-sandbox.mjs b/scripts/run-pylon-release-sandbox.mjs new file mode 100644 index 0000000000..a7504b720c --- /dev/null +++ b/scripts/run-pylon-release-sandbox.mjs @@ -0,0 +1,1399 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + appendFileSync, + chmodSync, + chownSync, + closeSync, + constants, + createWriteStream, + fchmodSync, + fchownSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + opendirSync, + readFileSync, + readSync, + realpathSync, + rmSync, + statSync, + writeFileSync, + writeSync, +} from "node:fs"; +import { get } from "node:https"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const PYLON_SANDBOX_POLICY = Object.freeze({ + image: "node@sha256:87a4f951f28b85d189df365d24c479d3bdb70be77c1ff5c9029db2ef67e251ac", + imageConfigId: "sha256:6622b5ce13429346f91fcdb936ec2e026ccc28465409fd25b7f79c623e7a20af", + platform: "linux/amd64", + nodeVersion: "22.23.2", + npmVersion: "11.10.1", + npmUrl: "https://registry.npmjs.org/npm/-/npm-11.10.1.tgz", + npmMaxBytes: 4 * 1024 * 1024, + npmSha256: "2190945151842685142f5085b3c5dd356b1021ab390d7d02c2bb2c580f0c4840", + npmSha512: "c286afb98d8e803150d4afed07d407b14b96f7cf6729fbec29337f879a86c92fb7f8186f5cdfc30364cdcf1e7af496857d3aab113e5b110347c1584593e53582", + npmSri: "sha512-woavuY2OgDFQ1K/tB9QHsUuW989nKfvsKTN/h5qGyS+3+BhvXN/DA2TNzx569JaFfTqrET5bEQNHwVhFk+U1gg==", + pidsLimit: "512", + cpus: "2", + memory: "3g", + maxSubjectBytes: 256 * 1024 * 1024, + maxTotalBytes: 512 * 1024 * 1024, +}); + +export const PYLON_SANDBOX_PUBLIC_FAIL_STAGES = Object.freeze([ + "E_ARGUMENTS", + "E_INITIALIZE", + "E_HOST", + "E_SOURCE", + "E_NPM", + "E_IMAGE_PULL", + "E_IMAGE_CONFIG", + "E_DEPENDENCIES", + "E_PACK", + "E_CONTRACTS", + "E_PREPARE", + "E_FINAL", + "E_ARTIFACT_INPUT", + "E_SMOKE_CACHE", + "E_SMOKE", + "E_RECEIPT", + "E_FINAL_COPY", + "E_CLEANUP", + "E_OUTPUT", + "E_INTERNAL", +]); +const publicFailStages = new Set(PYLON_SANDBOX_PUBLIC_FAIL_STAGES); +const containerFailStages = Object.freeze({ + contracts: "E_CONTRACTS", + dependencies: "E_DEPENDENCIES", + final: "E_FINAL", + pack: "E_PACK", + prepare: "E_PREPARE", + smoke: "E_SMOKE", + "smoke-cache": "E_SMOKE_CACHE", +}); +let publicFailStage = "E_ARGUMENTS"; + +function setPublicFailStage(stage) { + if (!publicFailStages.has(stage)) throw new Error(); + publicFailStage = stage; +} + +function setContainerFailStage(phase) { + const stage = containerFailStages[phase]; + if (stage === undefined) throw new Error(); + setPublicFailStage(stage); +} + +export function formatPublicFailure(stage) { + const safeStage = publicFailStages.has(stage) ? stage : "E_INTERNAL"; + return `::error::Pylon release sandbox failed closed [${safeStage}].`; +} + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const driverPath = "/source/scripts/run-pylon-release-sandbox.mjs"; +const hostTools = Object.freeze({ + docker: "/usr/bin/docker", + git: "/usr/bin/git", + tar: "/usr/bin/tar", +}); +const forbiddenEnvironment = /^(?:GITHUB_ENV|GITHUB_PATH|GITHUB_TOKEN|GH_TOKEN|BASH_ENV|NODE_OPTIONS|LD_.+|NPM_CONFIG_USERCONFIG)$/; + +function reject(message = "Release sandbox invariant failed.") { + throw new Error(message); +} + +function exactKeys(value, keys) { + if (value === null || typeof value !== "object" || Array.isArray(value)) reject(); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) reject(); +} + +function safeAbsolute(value) { + return typeof value === "string" && /^\/[A-Za-z0-9._/+:-]+$/.test(value) && resolve(value) === value; +} + +function phaseMounts(spec) { + if (["dependencies", "pack"].includes(spec.phase)) { + const mounts = [ + `type=bind,src=${spec.paths.build},dst=/build`, + `type=bind,src=${spec.paths.source},dst=/source,readonly`, + `type=bind,src=${spec.paths.git},dst=/build/.git,readonly`, + `type=bind,src=${spec.paths.npm},dst=/input/npm.tgz,readonly`, + ]; + if (spec.phase === "pack") mounts.push(`type=bind,src=${spec.paths.dependencies},dst=/build/node_modules,readonly`); + return mounts; + } + if (spec.phase === "contracts") { + return [ + `type=bind,src=${spec.paths.source},dst=/source,readonly`, + `type=bind,src=${spec.paths.dependencies},dst=/source/node_modules,readonly`, + `type=bind,src=${spec.paths.candidate},dst=/candidate`, + ]; + } + if (spec.phase === "prepare") { + return [ + `type=bind,src=${spec.paths.source},dst=/source,readonly`, + `type=bind,src=${spec.paths.candidate},dst=/candidate`, + ]; + } + if (spec.phase === "final") { + return [ + `type=bind,src=${spec.paths.source},dst=/source,readonly`, + `type=bind,src=${spec.paths.candidate},dst=/candidate,readonly`, + `type=bind,src=${spec.paths.control},dst=/control`, + ]; + } + if (["smoke-cache", "smoke"].includes(spec.phase)) { + return [ + `type=bind,src=${spec.paths.source},dst=/source,readonly`, + `type=bind,src=${spec.paths.artifacts},dst=/artifacts,readonly`, + `type=bind,src=${spec.paths.npm},dst=/input/npm.tgz,readonly`, + `type=bind,src=${spec.paths.cache},dst=/cache`, + ]; + } + reject(); +} + +function phaseEnvironment(spec) { + const safeDirectory = ["dependencies", "pack"].includes(spec.phase) ? "/build" : "/source"; + const values = [ + "HOME=/home/build", + `TMPDIR=${spec.phase === "smoke" ? "/smoke" : "/tmp"}`, + "PATH=/usr/local/bin:/usr/bin:/bin", + "CI=true", + "GITHUB_ACTIONS=true", + "GIT_OPTIONAL_LOCKS=0", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_COUNT=3", + "GIT_CONFIG_KEY_0=safe.directory", + `GIT_CONFIG_VALUE_0=${safeDirectory}`, + "GIT_CONFIG_KEY_1=core.fsmonitor", + "GIT_CONFIG_VALUE_1=false", + "GIT_CONFIG_KEY_2=core.hooksPath", + "GIT_CONFIG_VALUE_2=/dev/null", + ]; + if (["dependencies", "pack", "smoke-cache", "smoke"].includes(spec.phase)) values.push("npm_config_userconfig=/dev/null"); + if (["pack", "smoke-cache", "smoke"].includes(spec.phase)) values.push("PYLON_RELEASE_NPM_CLI=/tmp/npm-runtime/package/bin/npm-cli.js"); + if (["contracts", "prepare"].includes(spec.phase)) values.push(`GITHUB_RUN_ID=${spec.runId}`, `GITHUB_RUN_NUMBER=${spec.runNumber}`); + if (["smoke-cache", "smoke"].includes(spec.phase)) values.push("npm_config_cache=/cache"); + if (spec.phase === "smoke") values.push("npm_config_offline=true"); + return values; +} + +function phaseCommand(spec) { + return [ + "-i", + ...phaseEnvironment(spec), + "/usr/local/bin/node", + driverPath, + "--container-phase", + spec.phase, + "--mode", + spec.mode, + "--publication-policy-revision", + String(spec.publicationPolicyRevision), + ]; +} + +export function buildDockerRunArgs(spec) { + exactKeys(spec, ["cidfile", "gid", "mode", "name", "paths", "phase", "publicationPolicyRevision", "runId", "runNumber", "uid"]); + if (!/^[1-9][0-9]*$/.test(String(spec.uid)) || !/^[1-9][0-9]*$/.test(String(spec.gid))) reject(); + if (!/^[a-z0-9][a-z0-9_.-]{0,62}$/.test(spec.name) || !safeAbsolute(spec.cidfile)) reject(); + if (!["ci", "preview", "historical"].includes(spec.mode) || !["dependencies", "pack", "contracts", "prepare", "final", "smoke-cache", "smoke"].includes(spec.phase)) reject(); + if (spec.mode === "historical" && !["smoke-cache", "smoke"].includes(spec.phase)) reject(); + if (!Number.isSafeInteger(spec.publicationPolicyRevision) || spec.publicationPolicyRevision < 1) reject(); + if (!/^[0-9]+$/.test(spec.runId) || !/^[0-9]+$/.test(spec.runNumber)) reject(); + const pathKeys = spec.phase === "dependencies" ? ["build", "git", "npm", "source"] : + spec.phase === "pack" ? ["build", "dependencies", "git", "npm", "source"] : + spec.phase === "contracts" ? ["candidate", "dependencies", "source"] : + spec.phase === "prepare" ? ["candidate", "source"] : + spec.phase === "final" ? ["candidate", "control", "source"] : ["artifacts", "cache", "npm", "source"]; + exactKeys(spec.paths, pathKeys); + for (const value of Object.values(spec.paths)) if (!safeAbsolute(value) || value.includes(",")) reject(); + const network = ["dependencies", "smoke-cache"].includes(spec.phase) ? "bridge" : "none"; + const args = [ + "run", + "--name", + spec.name, + "--cidfile", + spec.cidfile, + "--pull", + "never", + "--platform", + PYLON_SANDBOX_POLICY.platform, + "--entrypoint", + "/usr/bin/env", + "--no-healthcheck", + "--init", + "--user", + `${spec.uid}:${spec.gid}`, + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + PYLON_SANDBOX_POLICY.pidsLimit, + "--cpus", + PYLON_SANDBOX_POLICY.cpus, + "--memory", + PYLON_SANDBOX_POLICY.memory, + "--memory-swap", + PYLON_SANDBOX_POLICY.memory, + "--read-only", + "--network", + network, + "--tmpfs", + `/home/build:rw,nosuid,nodev,noexec,size=256m,uid=${spec.uid},gid=${spec.gid},mode=700`, + "--tmpfs", + `/tmp:rw,nosuid,nodev,noexec,size=512m,uid=${spec.uid},gid=${spec.gid},mode=1777`, + ]; + if (spec.phase === "smoke") args.push("--tmpfs", `/smoke:rw,nosuid,nodev,exec,size=1024m,uid=${spec.uid},gid=${spec.gid},mode=1777`); + for (const mount of phaseMounts(spec)) args.push("--mount", mount); + args.push("--workdir", ["dependencies", "pack"].includes(spec.phase) ? "/build" : "/source"); + args.push(PYLON_SANDBOX_POLICY.image, ...phaseCommand(spec)); + return args; +} + +export function assertExactDockerRunArgs(args, spec) { + if (!Array.isArray(args) || args.some((value) => typeof value !== "string")) reject(); + const expected = buildDockerRunArgs(spec); + if (JSON.stringify(args) !== JSON.stringify(expected)) reject("Docker argv differs from the closed sandbox policy."); + const singletonValueFlags = ["--name", "--cidfile", "--pull", "--platform", "--entrypoint", "--user", "--cap-drop", "--security-opt", "--pids-limit", "--cpus", "--memory", "--memory-swap", "--network", "--workdir"]; + for (const flag of singletonValueFlags) if (args.filter((value) => value === flag).length !== 1) reject(); + for (const flag of ["--init", "--read-only", "--no-healthcheck"]) if (args.filter((value) => value === flag).length !== 1) reject(); + if (args.filter((value) => value === "run").length !== 1 || args[0] !== "run") reject(); + if (args.includes("--privileged") || args.includes("--cap-add") || args.includes("--pid") || args.includes("--env") || args.includes("--rm")) reject(); + const imageIndex = args.indexOf(PYLON_SANDBOX_POLICY.image); + if (imageIndex < 1 || args[imageIndex + 1] !== "-i" || args.slice(imageIndex + 1).some((value) => ["/bin/sh", "/usr/bin/bash", "sh", "bash"].includes(value))) reject(); + const mounts = args.flatMap((value, index) => args[index - 1] === "--mount" ? [value] : []); + const destinations = mounts.map((value) => value.match(/(?:^|,)dst=([^,]+)/)?.[1]); + if (destinations.some((value) => value === undefined) || new Set(destinations).size !== destinations.length) reject(); + if (mounts.some((value) => value.includes("docker.sock"))) reject(); + return true; +} + +function sanitizedHostEnvironment(home, nodePath = process.execPath) { + return { + HOME: home, + PATH: `${dirname(nodePath)}:/usr/bin:/bin`, + }; +} + +function runExact(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: options.capture ? "utf8" : undefined, + stdio: options.capture ? "pipe" : "inherit", + timeout: options.timeoutMs ?? 120_000, + maxBuffer: options.capture ? 1024 * 1024 : undefined, + }); + if (result.error || result.signal !== null || result.status !== 0) { + reject(options.description ?? "Exact subprocess failed."); + } + return options.capture ? result.stdout.trim() : undefined; +} + +function validateTool(path, { rootOwned = true } = {}) { + const link = lstatSync(path); + const resolved = realpathSync.native(path); + const metadata = lstatSync(resolved); + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o111) === 0) reject(); + if (rootOwned && ( + link.uid !== 0 || link.gid !== 0 || (!link.isSymbolicLink() && (link.mode & 0o022) !== 0) || + metadata.uid !== 0 || metadata.gid !== 0 || (metadata.mode & 0o022) !== 0 + )) reject(); +} + +function gitArgs(worktree, args) { + return [ + "-c", "core.fsmonitor=false", + "-c", "core.hooksPath=/dev/null", + "-c", `safe.directory=${worktree}`, + "-C", worktree, + ...args, + ]; +} + +function gitEnvironment(home) { + return { + HOME: home, + PATH: "/usr/bin:/bin", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_OPTIONAL_LOCKS: "0", + }; +} + +function gitValue(worktree, args, home) { + return runExact(hostTools.git, gitArgs(worktree, args), { + capture: true, + env: gitEnvironment(home), + description: "Trusted Git validation failed.", + }); +} + +function validateOriginal(workspace, expected, home) { + const dotGit = join(workspace, ".git"); + const gitMetadata = lstatSync(dotGit); + if (!gitMetadata.isDirectory() || gitMetadata.isSymbolicLink() || realpathSync.native(dotGit) !== dotGit) reject(); + const head = gitValue(workspace, ["rev-parse", "HEAD"], home); + const tree = gitValue(workspace, ["rev-parse", "HEAD^{tree}"], home); + const status = gitValue(workspace, ["status", "--porcelain=v1", "--untracked-files=all"], home); + if (!/^[0-9a-f]{40}$/.test(head) || !/^[0-9a-f]{40}$/.test(tree) || status !== "") reject(); + if (expected && (head !== expected.head || tree !== expected.tree)) reject(); + return { head, tree }; +} + +function chmodTreeReadOnly(path, traversal = { entries: 0 }, depth = 0) { + traversal.entries += 1; + if (traversal.entries > 200_000 || depth > 64) reject(); + const metadata = lstatSync(path); + if (metadata.isSymbolicLink()) return; + if (metadata.isDirectory()) { + const directory = opendirSync(path); + try { + for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { + chmodTreeReadOnly(join(path, entry.name), traversal, depth + 1); + } + } finally { + directory.closeSync(); + } + chmodSync(path, 0o555); + return; + } + if (!metadata.isFile()) reject(); + chmodSync(path, (metadata.mode & 0o111) === 0 ? 0o444 : 0o555); +} + +function preparePristineSource(workspace, scratch, home, identity, { withBuild }) { + const source = join(scratch, "source"); + const build = join(scratch, "build"); + const archive = join(scratch, "source.tar"); + const env = gitEnvironment(home); + runExact(hostTools.git, ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "clone", "--no-hardlinks", "--no-checkout", "--", workspace, source], { + env, + description: "Trusted source clone failed.", + }); + runExact(hostTools.git, gitArgs(source, ["checkout", "--detach", identity.head]), { env, description: "Trusted source checkout failed." }); + runExact(hostTools.git, gitArgs(source, ["remote", "set-url", "origin", "https://github.com/pylon-code/prime-agent.git"]), { env }); + if (gitValue(source, ["rev-parse", "HEAD"], home) !== identity.head || gitValue(source, ["rev-parse", "HEAD^{tree}"], home) !== identity.tree) reject(); + if (gitValue(source, ["status", "--porcelain=v1", "--untracked-files=all"], home) !== "") reject(); + const dotGit = join(source, ".git"); + if (!lstatSync(dotGit).isDirectory() || realpathSync.native(dotGit) !== dotGit) reject(); + mkdirSync(join(source, "node_modules"), { mode: 0o700 }); + if (withBuild) { + mkdirSync(build, { mode: 0o700 }); + runExact(hostTools.git, gitArgs(source, ["archive", "--format=tar", `--output=${archive}`, "HEAD"]), { env }); + runExact(hostTools.tar, ["--extract", "--file", archive, "--directory", build, "--no-same-owner"], { + env: sanitizedHostEnvironment(home), + description: "Trusted source archive extraction failed.", + }); + mkdirSync(join(build, ".git"), { mode: 0o555 }); + rmSync(archive, { force: true }); + } + chmodTreeReadOnly(source); + return { source, git: dotGit, build: withBuild ? build : undefined }; +} + +async function downloadNpmArchive(destination) { + const url = new URL(PYLON_SANDBOX_POLICY.npmUrl); + if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") reject(); + await new Promise((resolveDownload, rejectDownload) => { + const sha256 = createHash("sha256"); + const sha512 = createHash("sha512"); + let bytes = 0; + let overallTimeout; + let settled = false; + const output = createWriteStream(destination, { flags: "wx", mode: 0o400 }); + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(overallTimeout); + if (error) { + output.destroy(); + rejectDownload(error); + } else { + resolveDownload(); + } + }; + output.on("error", finish); + const request = get(url, { headers: { accept: "application/octet-stream", "user-agent": "pylon-release-sandbox-v1" }, timeout: 20_000 }, (response) => { + if (response.statusCode !== 200 || response.headers.location !== undefined) { + response.resume(); + finish(new Error()); + return; + } + const length = Number(response.headers["content-length"]); + if (!Number.isSafeInteger(length) || length < 1 || length > PYLON_SANDBOX_POLICY.npmMaxBytes) { + response.destroy(); + finish(new Error()); + return; + } + response.on("data", (chunk) => { + bytes += chunk.length; + if (bytes > PYLON_SANDBOX_POLICY.npmMaxBytes) { + response.destroy(); + finish(new Error()); + return; + } + sha256.update(chunk); + sha512.update(chunk); + if (!output.write(chunk)) response.pause(); + }); + output.on("drain", () => response.resume()); + response.on("end", () => { + output.end(() => { + const digest256 = sha256.digest("hex"); + const digest512 = sha512.digest(); + if ( + bytes !== length || digest256 !== PYLON_SANDBOX_POLICY.npmSha256 || + digest512.toString("hex") !== PYLON_SANDBOX_POLICY.npmSha512 || + `sha512-${digest512.toString("base64")}` !== PYLON_SANDBOX_POLICY.npmSri + ) finish(new Error()); + else finish(); + }); + }); + response.on("error", finish); + }); + overallTimeout = setTimeout(() => request.destroy(new Error()), 120_000); + request.on("timeout", () => request.destroy(new Error())); + request.on("error", finish); + }); + chmodSync(destination, 0o444); +} + +function streamEntries(directory, expectedCount) { + const entries = []; + const handle = opendirSync(directory); + try { + for (let entry = handle.readSync(); entry !== null; entry = handle.readSync()) { + entries.push(entry.name); + if (entries.length > expectedCount) reject(); + } + } finally { + handle.closeSync(); + } + if (entries.length !== expectedCount || entries.some((name) => !/^[A-Za-z0-9._-]+$/.test(name))) reject(); + return entries.sort(); +} + +const PINNED_STAT_KEYS = Object.freeze(["dev", "ino", "nlink", "size", "mode", "uid", "gid", "mtimeNs", "ctimeNs"]); + +function samePinnedStat(left, right) { + return PINNED_STAT_KEYS.every((key) => left[key] === right[key]); +} + +function preflightPinnedRegular(path, uid, gid, requiredMode, maxBytes = PYLON_SANDBOX_POLICY.maxSubjectBytes) { + const metadata = lstatSync(path, { bigint: true }); + if ( + !metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n || + metadata.uid !== BigInt(uid) || (gid !== null && metadata.gid !== BigInt(gid)) || + (metadata.mode & 0o7777n) !== BigInt(requiredMode) || metadata.size < 1n || + metadata.size > BigInt(maxBytes) || metadata.blocks * 512n < metadata.size + ) reject(); + return metadata; +} + +function readExactChunks(descriptor, size, onChunk) { + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Number(size))); + let offset = 0n; + while (offset < size) { + const wanted = Math.min(buffer.length, Number(size - offset)); + const count = readSync(descriptor, buffer, 0, wanted, null); + if (count < 1 || count > wanted) reject(); + onChunk(buffer.subarray(0, count)); + offset += BigInt(count); + } + const extra = Buffer.allocUnsafe(1); + if (readSync(descriptor, extra, 0, 1, null) !== 0) reject(); +} + +function writeExactChunk(descriptor, chunk) { + let offset = 0; + while (offset < chunk.length) { + const count = writeSync(descriptor, chunk, offset, chunk.length - offset, null); + if (count < 1 || count > chunk.length - offset) reject(); + offset += count; + } +} + +function digestPinnedRegular(path, before, uid, gid, requiredMode) { + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = fstatSync(descriptor, { bigint: true }); + if (!samePinnedStat(before, opened)) reject(); + const sha256 = createHash("sha256"); + readExactChunks(descriptor, opened.size, (chunk) => sha256.update(chunk)); + const after = fstatSync(descriptor, { bigint: true }); + const pathname = preflightPinnedRegular(path, uid, gid, requiredMode); + if (!samePinnedStat(opened, after) || !samePinnedStat(after, pathname)) reject(); + return sha256.digest("hex"); + } finally { + closeSync(descriptor); + } +} + +function copyPinnedRegular(input, output, before, uid, inputGid, outputGid, inputMode, outputMode, expected) { + const inputDescriptor = openSync(input, constants.O_RDONLY | constants.O_NOFOLLOW); + let outputDescriptor; + try { + const opened = fstatSync(inputDescriptor, { bigint: true }); + if (!samePinnedStat(before, opened)) reject(); + outputDescriptor = openSync(output, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600); + const sha256 = createHash("sha256"); + readExactChunks(inputDescriptor, opened.size, (chunk) => { + sha256.update(chunk); + writeExactChunk(outputDescriptor, chunk); + }); + const digest = sha256.digest("hex"); + const after = fstatSync(inputDescriptor, { bigint: true }); + const pathname = preflightPinnedRegular(input, uid, inputGid, inputMode); + if (!samePinnedStat(opened, after) || !samePinnedStat(after, pathname)) reject(); + if (expected && (expected.size !== Number(opened.size) || expected.sha256 !== digest)) reject(); + fchownSync(outputDescriptor, uid, outputGid); + fchmodSync(outputDescriptor, outputMode); + fsyncSync(outputDescriptor); + const copied = fstatSync(outputDescriptor, { bigint: true }); + if ( + !copied.isFile() || copied.nlink !== 1n || copied.uid !== BigInt(uid) || copied.gid !== BigInt(outputGid) || + (copied.mode & 0o7777n) !== BigInt(outputMode) || copied.size !== opened.size + ) reject(); + return { dev: copied.dev, ino: copied.ino, size: Number(copied.size), sha256: digest }; + } finally { + if (outputDescriptor !== undefined) closeSync(outputDescriptor); + closeSync(inputDescriptor); + } +} + +function validateReceipt(receipt, names) { + exactKeys(receipt, ["files", "schemaVersion"]); + if (receipt.schemaVersion !== 1 || !Array.isArray(receipt.files) || receipt.files.length !== names.length) reject(); + const files = new Map(); + for (const entry of receipt.files) { + exactKeys(entry, ["file", "sha256", "size"]); + if ( + typeof entry.file !== "string" || !/^[A-Za-z0-9._-]+$/.test(entry.file) || + !names.includes(entry.file) || files.has(entry.file) || !Number.isSafeInteger(entry.size) || + entry.size < 1 || entry.size > PYLON_SANDBOX_POLICY.maxSubjectBytes || !/^[0-9a-f]{64}$/.test(entry.sha256) + ) reject(); + files.set(entry.file, entry); + } + if (JSON.stringify([...files.keys()]) !== JSON.stringify(names)) reject(); + return files; +} + +function validateCopiedSubjects(source, destination, names, sourceStats, outputReceipts, { inputMode, outputMode, uid, gid, inputGid, expectedFiles }) { + if (JSON.stringify(streamEntries(destination, names.length)) !== JSON.stringify(names)) reject(); + for (const name of names) { + const input = join(source, name); + const output = join(destination, name); + const sourceBefore = sourceStats.get(name); + const sourceDigest = digestPinnedRegular(input, sourceBefore, uid, inputGid, inputMode); + const expected = expectedFiles?.get(name); + if (expected && (expected.size !== Number(sourceBefore.size) || expected.sha256 !== sourceDigest)) reject(); + const outputBefore = preflightPinnedRegular(output, uid, gid, outputMode); + const outputDigest = digestPinnedRegular(output, outputBefore, uid, gid, outputMode); + const copied = outputReceipts.get(name); + if ( + !copied || outputBefore.dev !== copied.dev || outputBefore.ino !== copied.ino || + Number(outputBefore.size) !== copied.size || outputDigest !== copied.sha256 || outputDigest !== sourceDigest + ) reject(); + } +} + +/** + * Workflow-private bridge. Its caller must own private source/destination parents and must prove + * every captured container and process group absent before copying a real publication candidate. + * This is not a general concurrent-writer boundary. + */ +export function copyBoundedSubjects(source, destination, { + expectedCount, + expectedNames, + expectedReceipt, + freeze = false, + gid = process.getgid(), + inputGid = gid, + inputMode = 0o644, + outputMode = 0o644, + sourceExpectedCount = expectedCount, + uid = process.getuid(), +} = {}) { + const sourceTop = lstatSync(source, { bigint: true }); + if (!sourceTop.isDirectory() || sourceTop.isSymbolicLink() || realpathSync.native(source) !== source) reject(); + const sourceNames = streamEntries(source, sourceExpectedCount); + const names = expectedNames === undefined ? sourceNames : [...expectedNames].sort(); + if ( + !Number.isSafeInteger(expectedCount) || names.length !== expectedCount || new Set(names).size !== names.length || + names.some((name) => !sourceNames.includes(name) || !/^[A-Za-z0-9._-]+$/.test(name)) + ) reject(); + const sourceStats = new Map(); + let total = 0; + for (const name of names) { + const metadata = preflightPinnedRegular(join(source, name), uid, inputGid, inputMode); + total += Number(metadata.size); + if (!Number.isSafeInteger(total) || total > PYLON_SANDBOX_POLICY.maxTotalBytes) reject(); + sourceStats.set(name, metadata); + } + const expectedFiles = expectedReceipt === undefined ? undefined : validateReceipt(expectedReceipt, names); + if (expectedFiles) { + const expectedTotal = [...expectedFiles.values()].reduce((sum, entry) => sum + entry.size, 0); + if (expectedTotal !== total) reject(); + } + mkdirSync(destination, { mode: 0o700 }); + const directoryDescriptor = openSync(destination, constants.O_RDONLY | constants.O_NOFOLLOW); + const outputReceipts = new Map(); + try { + fchownSync(directoryDescriptor, uid, gid); + fchmodSync(directoryDescriptor, 0o700); + const destinationTop = fstatSync(directoryDescriptor, { bigint: true }); + if (!destinationTop.isDirectory() || destinationTop.uid !== BigInt(uid) || destinationTop.gid !== BigInt(gid) || (destinationTop.mode & 0o7777n) !== 0o700n) reject(); + for (const name of names) { + const receipt = copyPinnedRegular( + join(source, name), join(destination, name), sourceStats.get(name), uid, inputGid, gid, inputMode, outputMode, expectedFiles?.get(name), + ); + outputReceipts.set(name, receipt); + } + validateCopiedSubjects(source, destination, names, sourceStats, outputReceipts, { inputMode, outputMode, uid, gid, inputGid, expectedFiles }); + if (freeze) { + for (const name of names) { + const descriptor = openSync(join(destination, name), constants.O_RDWR | constants.O_NOFOLLOW); + try { fchmodSync(descriptor, 0o444); fsyncSync(descriptor); } finally { closeSync(descriptor); } + const metadata = preflightPinnedRegular(join(destination, name), uid, gid, 0o444); + const receipt = outputReceipts.get(name); + outputReceipts.set(name, { ...receipt, dev: metadata.dev, ino: metadata.ino, size: Number(metadata.size) }); + } + fchmodSync(directoryDescriptor, 0o555); + } + fsyncSync(directoryDescriptor); + } finally { + closeSync(directoryDescriptor); + } + const finalMode = freeze ? 0o444 : outputMode; + validateCopiedSubjects(source, destination, names, sourceStats, outputReceipts, { inputMode, outputMode: finalMode, uid, gid, inputGid, expectedFiles }); + const finalTop = lstatSync(destination, { bigint: true }); + if ( + !finalTop.isDirectory() || finalTop.isSymbolicLink() || finalTop.uid !== BigInt(uid) || finalTop.gid !== BigInt(gid) || + (finalTop.mode & 0o7777n) !== BigInt(freeze ? 0o555 : 0o700) + ) reject(); + return Object.freeze({ + files: Object.freeze(names.map((name) => Object.freeze({ file: name, sha256: outputReceipts.get(name).sha256, size: outputReceipts.get(name).size }))), + names: Object.freeze(names), + totalBytes: total, + }); +} + +function freezeSubjectDirectory(path, expectedCount, uid = process.getuid(), gid = process.getgid()) { + const names = streamEntries(path, expectedCount); + for (const name of names) { + preflightPinnedRegular(join(path, name), uid, gid, 0o644); + const descriptor = openSync(join(path, name), constants.O_RDWR | constants.O_NOFOLLOW); + try { fchmodSync(descriptor, 0o444); fsyncSync(descriptor); } finally { closeSync(descriptor); } + const frozen = preflightPinnedRegular(join(path, name), uid, gid, 0o444); + digestPinnedRegular(join(path, name), frozen, uid, gid, 0o444); + } + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { fchmodSync(descriptor, 0o555); fsyncSync(descriptor); } finally { closeSync(descriptor); } + if ((lstatSync(path, { bigint: true }).mode & 0o7777n) !== 0o555n) reject(); +} + +function readPinnedSmall(path, uid, gid, requiredMode, maxBytes = 64 * 1024) { + const before = preflightPinnedRegular(path, uid, gid, requiredMode, maxBytes); + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = fstatSync(descriptor, { bigint: true }); + if (!samePinnedStat(before, opened)) reject(); + const chunks = []; + readExactChunks(descriptor, opened.size, (chunk) => chunks.push(Buffer.from(chunk))); + const after = fstatSync(descriptor, { bigint: true }); + const pathname = preflightPinnedRegular(path, uid, gid, requiredMode, maxBytes); + if (!samePinnedStat(opened, after) || !samePinnedStat(after, pathname)) reject(); + return Buffer.concat(chunks, Number(opened.size)); + } finally { + closeSync(descriptor); + } +} + +function canonicalReceipt(receipt) { + return `${JSON.stringify({ files: receipt.files.map((entry) => ({ file: entry.file, sha256: entry.sha256, size: entry.size })), schemaVersion: 1 })}\n`; +} + +function writeFinalReceipt(candidate, control, expectedCount) { + const names = streamEntries(candidate, expectedCount); + const files = names.map((name) => { + const metadata = preflightPinnedRegular(join(candidate, name), process.getuid(), process.getgid(), 0o444); + return { file: name, sha256: digestPinnedRegular(join(candidate, name), metadata, process.getuid(), process.getgid(), 0o444), size: Number(metadata.size) }; + }); + const bytes = Buffer.from(canonicalReceipt({ files }), "utf8"); + const path = join(control, "final-receipt.json"); + const descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + try { + writeExactChunk(descriptor, bytes); + fchownSync(descriptor, process.getuid(), process.getgid()); + fchmodSync(descriptor, 0o644); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + const directoryDescriptor = openSync(control, constants.O_RDONLY | constants.O_NOFOLLOW); + try { fsyncSync(directoryDescriptor); } finally { closeSync(directoryDescriptor); } +} + +export function readFinalReceipt(path, expectedCount, uid = process.getuid(), gid = process.getgid()) { + const bytes = readPinnedSmall(path, uid, gid, 0o644); + let receipt; + try { receipt = JSON.parse(bytes.toString("utf8")); } catch { reject(); } + if (!Array.isArray(receipt?.files) || receipt.files.length !== expectedCount) reject(); + const names = receipt.files.map((entry) => entry?.file); + if (names.some((name) => typeof name !== "string" || !/^[A-Za-z0-9._-]+$/.test(name))) reject(); + const sortedNames = [...names].sort(); + if (JSON.stringify(names) !== JSON.stringify(sortedNames)) reject(); + validateReceipt(receipt, names); + if (!bytes.equals(Buffer.from(canonicalReceipt(receipt), "utf8"))) reject(); + return receipt; +} + +function parseCid(path) { + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || metadata.uid !== process.getuid() || (metadata.mode & 0o022) !== 0 || metadata.size < 64 || metadata.size > 65) reject(); + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = fstatSync(descriptor); + if (opened.dev !== metadata.dev || opened.ino !== metadata.ino || opened.size !== metadata.size) reject(); + const buffer = Buffer.alloc(65); + const count = readSync(descriptor, buffer, 0, buffer.length, null); + if (count !== metadata.size || readSync(descriptor, buffer, 0, 1, null) !== 0) reject(); + const value = buffer.subarray(0, count).toString("utf8").trim(); + const after = fstatSync(descriptor); + const pathname = lstatSync(path); + if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || pathname.dev !== opened.dev || pathname.ino !== opened.ino || pathname.size !== opened.size || !/^[0-9a-f]{64}$/.test(value)) reject(); + return value; + } finally { + closeSync(descriptor); + } +} + +function dockerEnvironment(home) { + return { HOME: home, PATH: "/usr/bin:/bin" }; +} + +function dockerCaptured(state, args, timeout = 30_000) { + return spawnSync(hostTools.docker, args, { + encoding: "utf8", + env: dockerEnvironment(state.home), + maxBuffer: 1024 * 1024, + stdio: "pipe", + timeout, + }); +} + +export function assertContainerRemoved(id, removal, inspection) { + if (!/^[0-9a-f]{64}$/.test(id)) reject(); + const missing = !inspection.error && inspection.signal === null && inspection.status === 1 && + typeof inspection.stderr === "string" && inspection.stderr.includes("No such container") && inspection.stderr.includes(id); + if (removal.error || removal.signal !== null || removal.status !== 0 || removal.stdout?.trim() !== id || !missing) reject(); + return true; +} + +function captureContainerId(state, receipt) { + if (lstatExists(receipt.cidfile)) return parseCid(receipt.cidfile); + const result = dockerCaptured(state, ["container", "inspect", "--format", "{{.Id}}", receipt.name], 20_000); + const id = result.stdout?.trim(); + if (result.error || result.signal !== null || result.status !== 0 || !/^[0-9a-f]{64}$/.test(id ?? "")) reject(); + return id; +} + +function runDockerPhase(state, phase, paths, options) { + setContainerFailStage(phase); + for (const value of Object.values(paths)) { + if (realpathSync.native(value) !== value || (!value.startsWith(`${state.scratch}/`) && !value.startsWith(`${state.control}/`))) reject(); + } + const name = `pylon-${phase}-${state.nonce}`; + const cidfile = join(state.cidDirectory, `${phase}.cid`); + const spec = { + cidfile, + gid: String(process.getgid()), + mode: options.mode, + name, + paths, + phase, + publicationPolicyRevision: options.publicationPolicyRevision, + runId: options.runId, + runNumber: options.runNumber, + uid: String(process.getuid()), + }; + const args = buildDockerRunArgs(spec); + assertExactDockerRunArgs(args, spec); + const receipt = { cidfile, id: undefined, name }; + state.containers.push(receipt); + const result = spawnSync(hostTools.docker, args, { + env: dockerEnvironment(state.home), + stdio: "inherit", + timeout: ["smoke", "contracts"].includes(phase) ? 12 * 60_000 : 10 * 60_000, + }); + receipt.id = captureContainerId(state, receipt); + if (result.error || result.signal !== null || result.status !== 0) reject(`Container phase ${phase} failed.`); +} + +function lstatExists(path) { + try { lstatSync(path); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } +} + +function cleanupContainers(state) { + let clean = true; + for (const receipt of [...state.containers].reverse()) { + let id = receipt.id; + if (id === undefined) { + try { id = captureContainerId(state, receipt); } catch { clean = false; continue; } + } + const removal = dockerCaptured(state, ["container", "rm", "--force", id], 30_000); + const inspection = dockerCaptured(state, ["container", "inspect", id], 20_000); + try { assertContainerRemoved(id, removal, inspection); } catch { clean = false; } + } + if (clean) state.containers.length = 0; + return clean; +} + +async function terminateActiveProcessGroup(state) { + const pgid = state.activePgid; + if (pgid === null) return true; + if (!Number.isSafeInteger(pgid) || pgid < 1) return false; + try { process.kill(-pgid, "SIGKILL"); } catch (error) { if (error?.code !== "ESRCH") return false; } + const drained = await waitForProcessGroupDrain(pgid, 3_000); + if (drained && state.activePgid === pgid) state.activePgid = null; + return drained; +} + +export function installSignalCleanup(state, hooks = {}) { + const signalTarget = hooks.signalTarget ?? process; + const cleanupContainersFn = hooks.cleanupContainers ?? cleanupContainers; + const removeOwnedTreeFn = hooks.removeOwnedTree ?? removeOwnedTree; + const exit = hooks.exit ?? ((code) => process.exit(code)); + const yieldForPendingSignal = hooks.yieldForPendingSignal ?? (() => new Promise((resolveYield) => setImmediate(resolveYield))); + const handlers = new Map(); + let handlingPromise; + const removeHandlers = () => { + for (const [signal, handler] of handlers) signalTarget.off(signal, handler); + }; + for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + if (handlingPromise) return; + handlingPromise = (async () => { + let clean = await terminateActiveProcessGroup(state); + try { clean = cleanupContainersFn(state) && clean; } catch { clean = false; } + try { removeOwnedTreeFn(state.scratch); } catch { clean = false; } + try { removeOwnedTreeFn(state.control); } catch { clean = false; } + removeHandlers(); + exit(clean ? (signal === "SIGINT" ? 130 : 143) : 1); + })().catch(() => { + removeHandlers(); + exit(1); + }); + }; + handlers.set(signal, handler); + signalTarget.on(signal, handler); + } + return async () => { + await yieldForPendingSignal(); + if (handlingPromise) await handlingPromise; + else removeHandlers(); + }; +} + +function absentNullOrEmptyString(value) { + return value === undefined || value === null || value === ""; +} + +function absentNullOrEmptyArray(value) { + return value === undefined || value === null || (Array.isArray(value) && value.length === 0); +} + +function absentNullOrEmptyObject(value) { + return value === undefined || value === null || ( + typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0 + ); +} + +export function validateImageInspection(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) reject(); + if ( + value.Os !== "linux" || value.Architecture !== "amd64" || + !absentNullOrEmptyString(value.Variant) || value.Id !== PYLON_SANDBOX_POLICY.imageConfigId + ) reject(); + const allowedRepoDigests = new Set([ + PYLON_SANDBOX_POLICY.image, + `docker.io/library/${PYLON_SANDBOX_POLICY.image}`, + ]); + if ( + !Array.isArray(value.RepoDigests) || value.RepoDigests.length < 1 || value.RepoDigests.length > allowedRepoDigests.size || + new Set(value.RepoDigests).size !== value.RepoDigests.length || + value.RepoDigests.some((digest) => !allowedRepoDigests.has(digest)) + ) reject(); + const config = value.Config; + if (config === null || typeof config !== "object" || Array.isArray(config)) reject(); + if ( + JSON.stringify(config.Entrypoint) !== JSON.stringify(["docker-entrypoint.sh"]) || + JSON.stringify(config.Cmd) !== JSON.stringify(["node"]) || + JSON.stringify(config.Env) !== JSON.stringify([ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + `NODE_VERSION=${PYLON_SANDBOX_POLICY.nodeVersion}`, + "YARN_VERSION=1.22.22", + ]) || + !absentNullOrEmptyObject(config.Volumes) || + !absentNullOrEmptyObject(config.Healthcheck) || + !absentNullOrEmptyString(config.User) || + !absentNullOrEmptyString(config.WorkingDir) || + !absentNullOrEmptyObject(config.ExposedPorts) || + !absentNullOrEmptyArray(config.OnBuild) || + !absentNullOrEmptyArray(config.Shell) || + !absentNullOrEmptyString(config.StopSignal) || + !(config.StopTimeout === undefined || config.StopTimeout === null) || + !(config.NetworkDisabled === undefined || config.NetworkDisabled === null || config.NetworkDisabled === false) || + !absentNullOrEmptyString(config.Hostname) || + !absentNullOrEmptyString(config.Domainname) || + !absentNullOrEmptyString(config.MacAddress) + ) reject(); + return true; +} + +function pullImage(state) { + setPublicFailStage("E_IMAGE_PULL"); + validateTool(hostTools.docker); + runExact(hostTools.docker, ["pull", "--platform", PYLON_SANDBOX_POLICY.platform, PYLON_SANDBOX_POLICY.image], { + env: dockerEnvironment(state.home), timeoutMs: 5 * 60_000, description: "Pinned image pull failed.", + }); + setPublicFailStage("E_IMAGE_CONFIG"); + const inspected = runExact(hostTools.docker, ["image", "inspect", "--format", "{{json .}}", PYLON_SANDBOX_POLICY.image], { + capture: true, env: dockerEnvironment(state.home), description: "Pinned image inspection failed.", + }); + let value; + try { value = JSON.parse(inspected); } catch { reject(); } + validateImageInspection(value); +} + +function verifyNpmArchive(path) { + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size < 1 || metadata.size > PYLON_SANDBOX_POLICY.npmMaxBytes) reject(); + const bytes = readFileSync(path); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const sha512 = createHash("sha512").update(bytes).digest(); + if (sha256 !== PYLON_SANDBOX_POLICY.npmSha256 || sha512.toString("hex") !== PYLON_SANDBOX_POLICY.npmSha512 || `sha512-${sha512.toString("base64")}` !== PYLON_SANDBOX_POLICY.npmSri) reject(); +} + +function extractNpmRuntime(archive, destination, env) { + verifyNpmArchive(archive); + mkdirSync(destination, { mode: 0o700 }); + runExact(hostTools.tar, ["--extract", "--gzip", "--file", archive, "--directory", destination, "--no-same-owner"], { env }); + const cli = join(destination, "package", "bin", "npm-cli.js"); + if (!lstatSync(cli).isFile()) reject(); + const version = runExact(process.execPath, [cli, "--version"], { capture: true, env, description: "Pinned npm validation failed." }); + if (version !== PYLON_SANDBOX_POLICY.npmVersion) reject(); + return cli; +} + +function verifySmokeArtifacts(options) { + if (options.mode === "ci") { + runExact(process.execPath, ["/source/scripts/verify-pylon-prime-agent-release.mjs", "--artifact-dir", "/artifacts"], { cwd: "/source", env: process.env }); + return JSON.parse(readFileSync("/artifacts/pylon-prime-agent-release-v1.json", "utf8")); + } + const historical = options.mode === "historical" ? ["--historical"] : []; + runExact(process.execPath, ["/source/scripts/verify-pylon-preview-publication.mjs", ...historical, "--artifact-dir", "/artifacts"], { cwd: "/source", env: process.env }); + return JSON.parse(readFileSync("/artifacts/pylon-prime-agent-release-v1.json", "utf8")); +} + +function prepareSmokeCache(options, npmCli) { + const release = verifySmokeArtifacts(options); + if (!Array.isArray(release.assets) || release.assets.length !== 4) reject(); + const packages = new Map(); + for (const asset of release.assets) { + if ( + asset === null || typeof asset !== "object" || typeof asset.package !== "string" || + typeof asset.file !== "string" || !/^[A-Za-z0-9._-]+$/.test(asset.file) || packages.has(asset.package) + ) reject(); + packages.set(asset.package, `file:///artifacts/${asset.file}`); + } + const rootArchive = packages.get("prime-agent"); + if (rootArchive === undefined) reject(); + const overrides = Object.fromEntries([...packages.entries()].filter(([name]) => name !== "prime-agent")); + const consumer = "/tmp/cache-consumer"; + mkdirSync(consumer, { mode: 0o700 }); + writeFileSync(join(consumer, "package.json"), `${JSON.stringify({ name: "pylon-prime-artifact-cache", private: true, dependencies: { "prime-agent": rootArchive }, overrides }, null, "\t")}\n`, { flag: "wx", mode: 0o600 }); + runExact(process.execPath, [npmCli, "install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"], { + cwd: consumer, + env: process.env, + timeoutMs: 10 * 60_000, + }); +} + +function containerPhase(options) { + process.umask(0o022); + if (process.execPath !== "/usr/local/bin/node" || process.version !== `v${PYLON_SANDBOX_POLICY.nodeVersion}`) reject(); + for (const name of Object.keys(process.env)) if (forbiddenEnvironment.test(name)) reject(); + if (["dependencies", "pack", "smoke-cache", "smoke"].includes(options.containerPhase)) { + const npmCli = extractNpmRuntime("/input/npm.tgz", "/tmp/npm-runtime", process.env); + if (options.containerPhase === "dependencies") { + runExact(process.execPath, [npmCli, "ci", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: "/build", env: process.env, timeoutMs: 8 * 60_000 }); + return; + } + if (options.containerPhase === "pack") { + runExact(process.execPath, [npmCli, "run", "release:pylon:pack"], { cwd: "/build", env: process.env, timeoutMs: 8 * 60_000 }); + return; + } + if (options.containerPhase === "smoke-cache") { + prepareSmokeCache(options, npmCli); + return; + } + } + if (options.containerPhase === "contracts") { + const contractTemp = "/candidate/.contract-tmp"; + mkdirSync(contractTemp, { mode: 0o700 }); + const contractEnvironment = { ...process.env, TMPDIR: contractTemp }; + try { + runExact(process.execPath, ["--test", "/source/scripts/pylon-prime-agent-release.test.mjs"], { cwd: "/source", env: contractEnvironment, timeoutMs: 3 * 60_000 }); + runExact(process.execPath, ["--test", "/source/scripts/pylon-publication.test.mjs"], { cwd: "/source", env: contractEnvironment, timeoutMs: 3 * 60_000 }); + } finally { + removeOwnedTree(contractTemp); + } + return; + } + if (options.containerPhase === "prepare") { + runExact(process.execPath, ["/source/scripts/verify-pylon-prime-agent-release.mjs", "--artifact-dir", "/candidate"], { cwd: "/source", env: process.env }); + if (options.mode === "preview") { + runExact(process.execPath, ["/source/scripts/prepare-pylon-preview-manifest.mjs", "--artifact-dir", "/candidate", "--publication-policy-revision", String(options.publicationPolicyRevision)], { cwd: "/source", env: process.env }); + } + return; + } + if (options.containerPhase === "final") { + const verifier = options.mode === "preview" ? "/source/scripts/verify-pylon-preview-publication.mjs" : "/source/scripts/verify-pylon-prime-agent-release.mjs"; + runExact(process.execPath, [verifier, "--artifact-dir", "/candidate"], { cwd: "/source", env: process.env }); + writeFinalReceipt("/candidate", "/control", options.mode === "preview" ? 6 : 5); + return; + } + if (options.containerPhase === "smoke") { + verifySmokeArtifacts(options); + const historical = options.mode === "historical"; + let artifacts = "/artifacts"; + if (options.mode !== "ci") { + artifacts = "/tmp/release-artifacts"; + releaseOnlyCopy("/artifacts", artifacts, { historical }); + } + runExact(process.execPath, ["/source/scripts/smoke-pylon-prime-agent-release.mjs", ...(historical ? ["--historical"] : []), "--artifact-dir", artifacts], { cwd: "/source", env: process.env, timeoutMs: 10 * 60_000 }); + return; + } + reject(); +} + +function parseArguments(args) { + if (args.length % 2 !== 0) reject(); + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key.startsWith("--") || values.has(key)) reject(); + values.set(key, value); + } + if (values.has("--container-phase")) { + const allowed = new Set(["--container-phase", "--mode", "--publication-policy-revision"]); + if ([...values.keys()].some((key) => !allowed.has(key))) reject(); + const publicationPolicyRevision = Number(values.get("--publication-policy-revision")); + const result = { containerPhase: values.get("--container-phase"), mode: values.get("--mode"), publicationPolicyRevision }; + if (!["dependencies", "pack", "contracts", "prepare", "final", "smoke-cache", "smoke"].includes(result.containerPhase) || !["ci", "preview", "historical"].includes(result.mode) || !Number.isSafeInteger(publicationPolicyRevision) || publicationPolicyRevision < 1) reject(); + if (result.mode === "historical" && !["smoke-cache", "smoke"].includes(result.containerPhase)) reject(); + return result; + } + const allowed = new Set(["--artifact-dir", "--github-output", "--host-os", "--mode", "--publication-policy-revision", "--run-id", "--run-number", "--task", "--workspace"]); + if ([...values.keys()].some((key) => !allowed.has(key))) reject(); + const result = { + artifactDir: values.get("--artifact-dir"), + githubOutput: values.get("--github-output"), + hostOs: values.get("--host-os"), + mode: values.get("--mode"), + publicationPolicyRevision: Number(values.get("--publication-policy-revision")), + runId: values.get("--run-id"), + runNumber: values.get("--run-number"), + task: values.get("--task"), + workspace: values.get("--workspace"), + }; + if (!["pack", "smoke"].includes(result.task) || !["ci", "preview", "historical"].includes(result.mode) || !["Linux", "macOS"].includes(result.hostOs)) reject(); + if (result.mode === "historical" && result.task !== "smoke") reject(); + if (!Number.isSafeInteger(result.publicationPolicyRevision) || result.publicationPolicyRevision < 1 || !/^[0-9]+$/.test(result.runId ?? "") || !/^[0-9]+$/.test(result.runNumber ?? "")) reject(); + if (!safeAbsolute(result.workspace) || (result.task === "pack" && (!safeAbsolute(result.githubOutput) || result.hostOs !== "Linux")) || (result.task === "smoke" && !safeAbsolute(result.artifactDir))) reject(); + return result; +} + +function removeOwnedTree(path) { + if (!lstatExists(path)) return; + const metadata = lstatSync(path); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + chmodSync(path, 0o700); + const directory = opendirSync(path); + try { + for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) removeOwnedTree(join(path, entry.name)); + } finally { + directory.closeSync(); + } + } + rmSync(path, { force: true, recursive: true }); +} + +function makeState() { + const control = realpathSync.native(mkdtempSync("/tmp/pylon-release-control.")); + const scratch = realpathSync.native(mkdtempSync("/tmp/pylon-release-scratch.")); + chmodSync(control, 0o700); + chmodSync(scratch, 0o700); + const home = join(control, "home"); + const cidDirectory = join(control, "cids"); + mkdirSync(home, { mode: 0o700 }); + mkdirSync(cidDirectory, { mode: 0o700 }); + return { activePgid: null, cidDirectory, containers: [], control, home, nonce: control.split(".").at(-1).toLowerCase(), scratch }; +} + +function validateHost(options) { + if (process.version !== `v${PYLON_SANDBOX_POLICY.nodeVersion}` || process.getuid() === 0 || process.getgid() === 0) reject(); + const workspace = realpathSync.native(options.workspace); + if (workspace !== options.workspace || workspace !== root) reject(); + validateTool(hostTools.git); + validateTool(hostTools.tar); + validateTool(process.execPath, { rootOwned: false }); + return workspace; +} + +function processGroupIsAlive(pid) { + try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } +} + +async function waitForProcessGroupDrain(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (processGroupIsAlive(pid) && Date.now() < deadline) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } + return !processGroupIsAlive(pid); +} + +export async function runDetachedProcessGroup(command, args, options) { + const lifecycle = options.lifecycle ?? { activePgid: null }; + if (lifecycle.activePgid !== null) reject(); + return await new Promise((resolveRun, rejectRun) => { + const child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: true, stdio: "inherit" }); + if (!Number.isSafeInteger(child.pid) || child.pid < 1) { + child.once("error", rejectRun); + child.once("close", () => rejectRun(new Error())); + return; + } + const pgid = child.pid; + lifecycle.activePgid = pgid; + const receipt = { pid: pgid, status: undefined, signal: undefined }; + let settled = false; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { process.kill(-pgid, "SIGKILL"); } catch {} + }, options.timeoutMs); + child.once("error", async (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + await terminateActiveProcessGroup(lifecycle); + rejectRun(error); + }); + child.once("close", async (status, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + receipt.status = status; + receipt.signal = signal; + const groupAlive = processGroupIsAlive(pgid); + if (groupAlive) { + try { process.kill(-pgid, "SIGKILL"); } catch {} + } + const drained = !groupAlive || await waitForProcessGroupDrain(pgid, 3_000); + if (drained && lifecycle.activePgid === pgid) lifecycle.activePgid = null; + if (!drained || timedOut || groupAlive || status !== 0 || signal !== null) rejectRun(new Error()); + else resolveRun(receipt); + }); + }); +} + +export function releaseOnlyCopy(source, destination, { historical }) { + const releaseBytes = readPinnedSmall(join(source, "pylon-prime-agent-release-v1.json"), process.getuid(), process.getgid(), 0o444); + let release; + try { release = JSON.parse(releaseBytes.toString("utf8")); } catch { reject(); } + if (!Array.isArray(release.assets) || release.assets.length !== 4) reject(); + const expected = new Set([...release.assets.map((asset) => asset.file), "pylon-prime-agent-release-v1.json"]); + if (historical) expected.add("pylon-preview-channel-v1.json"); + if (expected.size !== (historical ? 6 : 5)) reject(); + return copyBoundedSubjects(source, destination, { + expectedCount: expected.size, + expectedNames: [...expected], + inputMode: 0o444, + sourceExpectedCount: 6, + }); +} + +async function runMacSmoke(state, options, source, artifacts, npmArchive) { + const runtime = join(state.scratch, "npm-runtime"); + const temp = join(state.control, "tmp"); + mkdirSync(temp, { mode: 0o700 }); + const env = { + HOME: state.home, + TMPDIR: temp, + PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, + CI: "true", + GITHUB_ACTIONS: "true", + GIT_OPTIONAL_LOCKS: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_0: "safe.directory", + GIT_CONFIG_VALUE_0: source, + GIT_CONFIG_KEY_1: "core.fsmonitor", + GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "core.hooksPath", + GIT_CONFIG_VALUE_2: "/dev/null", + npm_config_userconfig: "/dev/null", + }; + const npmCli = extractNpmRuntime(npmArchive, runtime, env); + env.PYLON_RELEASE_NPM_CLI = npmCli; + let smokeArtifacts = artifacts; + if (options.mode !== "ci") { + const historical = options.mode === "historical"; + runExact(process.execPath, [join(source, "scripts/verify-pylon-preview-publication.mjs"), ...(historical ? ["--historical"] : []), "--artifact-dir", artifacts], { cwd: source, env }); + smokeArtifacts = join(state.scratch, "release-artifacts"); + releaseOnlyCopy(artifacts, smokeArtifacts, { historical }); + } else { + runExact(process.execPath, [join(source, "scripts/verify-pylon-prime-agent-release.mjs"), "--artifact-dir", artifacts], { cwd: source, env }); + } + const historical = options.mode === "historical" ? ["--historical"] : []; + const receipt = await runDetachedProcessGroup(process.execPath, [join(source, "scripts/smoke-pylon-prime-agent-release.mjs"), ...historical, "--artifact-dir", smokeArtifacts], { + cwd: source, env, lifecycle: state, timeoutMs: 10 * 60_000, + }); + if (!Number.isSafeInteger(receipt.pid) || receipt.pid < 1 || receipt.status !== 0 || receipt.signal !== null) reject(); +} + +export function exactArtifactDirectory(path) { + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) reject(); + const resolved = realpathSync.native(path); + if (resolved !== path) reject(); + return resolved; +} + +async function orchestrate(options) { + setPublicFailStage("E_INITIALIZE"); + const state = makeState(); + const removeSignalHandlers = installSignalCleanup(state); + let success = false; + let operationFailureStage; + let finalOutput; + try { + setPublicFailStage("E_HOST"); + const workspace = validateHost(options); + const identity = validateOriginal(workspace, undefined, state.home); + setPublicFailStage("E_SOURCE"); + const pristine = preparePristineSource(workspace, state.scratch, state.home, identity, { withBuild: options.task === "pack" }); + const npmArchive = join(state.scratch, "npm-11.10.1.tgz"); + setPublicFailStage("E_NPM"); + await downloadNpmArchive(npmArchive); + if (options.task === "pack") { + pullImage(state); + runDockerPhase(state, "dependencies", { build: pristine.build, git: pristine.git, npm: npmArchive, source: pristine.source }, options); + const dependencies = join(pristine.build, "node_modules"); + if (!lstatSync(dependencies).isDirectory() || realpathSync.native(dependencies) !== dependencies) reject(); + chmodTreeReadOnly(dependencies); + runDockerPhase(state, "pack", { build: pristine.build, dependencies, git: pristine.git, npm: npmArchive, source: pristine.source }, options); + const candidate = join(state.scratch, "candidate"); + copyBoundedSubjects(join(pristine.build, ".npm", "pylon-release", "artifacts"), candidate, { expectedCount: 5 }); + const contractCandidate = join(state.scratch, "contract-candidate"); + copyBoundedSubjects(candidate, contractCandidate, { expectedCount: 5 }); + runDockerPhase(state, "contracts", { candidate: contractCandidate, dependencies, source: pristine.source }, options); + setPublicFailStage("E_CLEANUP"); + if (!cleanupContainers(state)) reject("Captured container cleanup failed."); + removeOwnedTree(contractCandidate); + runDockerPhase(state, "prepare", { candidate, source: pristine.source }, options); + freezeSubjectDirectory(candidate, options.mode === "preview" ? 6 : 5); + const receiptControl = join(state.control, "verification"); + mkdirSync(receiptControl, { mode: 0o700 }); + runDockerPhase(state, "final", { candidate, control: receiptControl, source: pristine.source }, options); + setPublicFailStage("E_CLEANUP"); + if (!cleanupContainers(state)) reject("Captured container cleanup failed."); + const expectedCount = options.mode === "preview" ? 6 : 5; + setPublicFailStage("E_RECEIPT"); + const finalReceipt = readFinalReceipt(join(receiptControl, "final-receipt.json"), expectedCount); + setPublicFailStage("E_SOURCE"); + validateOriginal(workspace, identity, state.home); + setPublicFailStage("E_FINAL_COPY"); + finalOutput = join(state.control, "final-artifacts"); + copyBoundedSubjects(candidate, finalOutput, { expectedCount, expectedReceipt: finalReceipt, freeze: true, inputMode: 0o444 }); + } else { + setPublicFailStage("E_ARTIFACT_INPUT"); + const artifactInput = join(state.scratch, "artifact-input"); + copyBoundedSubjects(exactArtifactDirectory(options.artifactDir), artifactInput, { + expectedCount: options.mode === "ci" ? 5 : 6, + freeze: true, + inputGid: options.hostOs === "macOS" ? null : process.getgid(), + }); + if (options.hostOs === "Linux") { + pullImage(state); + const cache = join(state.scratch, "smoke-cache"); + mkdirSync(cache, { mode: 0o700 }); + runDockerPhase(state, "smoke-cache", { artifacts: artifactInput, cache, npm: npmArchive, source: pristine.source }, options); + runDockerPhase(state, "smoke", { artifacts: artifactInput, cache, npm: npmArchive, source: pristine.source }, options); + } else { + setPublicFailStage("E_SMOKE"); + await runMacSmoke(state, options, pristine.source, artifactInput, npmArchive); + } + } + success = true; + } catch (error) { + operationFailureStage = publicFailStage; + throw error; + } finally { + setPublicFailStage("E_CLEANUP"); + let processGroupClean = false; + let containersClean = false; + try { + processGroupClean = await terminateActiveProcessGroup(state); + containersClean = options.hostOs !== "Linux" || cleanupContainers(state); + removeOwnedTree(state.scratch); + if (!success || options.task === "smoke") removeOwnedTree(state.control); + else { + removeOwnedTree(state.home); + removeOwnedTree(state.cidDirectory); + } + } finally { + await removeSignalHandlers(); + } + if (!processGroupClean) reject("Captured process-group cleanup failed."); + if (!containersClean) reject("Captured container cleanup failed."); + if (operationFailureStage !== undefined) setPublicFailStage(operationFailureStage); + } + if (options.task === "pack") { + setPublicFailStage("E_OUTPUT"); + appendFileSync(options.githubOutput, `artifact_dir=${finalOutput} +`, { encoding: "utf8" }); + } +} + +const isMain = process.argv[1] !== undefined && realpathSync.native(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + try { + setPublicFailStage("E_ARGUMENTS"); + const options = parseArguments(process.argv.slice(2)); + if ("containerPhase" in options) { + setContainerFailStage(options.containerPhase); + containerPhase(options); + } else await orchestrate(options); + } catch { + console.error(formatPublicFailure(publicFailStage)); + process.exitCode = 1; + } +}