From c2aa1ec0c31fac84bf347e3f0ec24666a09d0e35 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:01:54 -0500 Subject: [PATCH 1/2] fix(ci): validate shared Cloud image contexts throughout the stack Co-Authored-By: Paperclip --- .github/workflows/docker-cloud.yml | 14 +++++++++ .github/workflows/docker.yml | 24 ++++++++++++++ .../src/__tests__/docker-build-stamp.test.ts | 31 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml index 62025d3a85..38fb18e2f7 100644 --- a/.github/workflows/docker-cloud.yml +++ b/.github/workflows/docker-cloud.yml @@ -3,6 +3,13 @@ name: Docker cloud on: workflow_dispatch: workflow_call: + inputs: + staging_artifact_base_url: + type: string + default: "" + staging_lock_sha256: + type: string + default: "" permissions: {} @@ -98,9 +105,16 @@ jobs: node-version: 24 - name: Refresh lockfile for Docker build context + env: + STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict + fi changed="$(git status --porcelain)" if [ -z "$changed" ]; then diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bbb830b080..d74f75fd1d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,6 +19,11 @@ on: type: string default: "" + staging_lock_sha256: + description: Reviewed SHA-256 of the resolved pnpm 9 lockfile; required with staging_artifact_base_url + type: string + default: "" + # Least privilege: nothing at the workflow level; each job declares exactly # the token scopes it uses (checkout needs contents:read, GHCR pushes need # packages:write). @@ -59,6 +64,14 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 + - name: Resolve and verify staging dependencies before installation + env: + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} + run: | + set -euo pipefail + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict - run: pnpm install --frozen-lockfile - name: Build matching migrator artifacts env: @@ -172,10 +185,18 @@ jobs: node-version: 24 - name: Refresh lockfile for Docker build context + env: + STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict + fi + changed="$(git status --porcelain)" if [ -z "$changed" ]; then echo "Lockfile already matches package metadata." @@ -406,6 +427,9 @@ jobs: build-and-push-cloud: if: github.event_name != 'push' || github.ref != 'refs/heads/master' uses: ./.github/workflows/docker-cloud.yml + with: + staging_artifact_base_url: ${{ inputs.staging_artifact_base_url || '' }} + staging_lock_sha256: ${{ inputs.staging_lock_sha256 || '' }} permissions: contents: read packages: write diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 4794989feb..26a4737957 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -76,3 +76,34 @@ describe("docker build-stamp wiring", () => { ).toBeGreaterThanOrEqual(2); }); }); + + +describe("staging dependency integrity", () => { + it("verifies the reviewed resolved lock before installing the migrator", () => { + const migrator = workflow.split(" staging-migrator:")[1].split(" build-and-push:")[0]; + const verification = migrator.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict'); + expect(migrator).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}"); + expect(migrator).toContain('[[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]]'); + expect(verification).toBeGreaterThan(migrator.indexOf("pnpm install --resolution-only --ignore-scripts")); + expect(verification).toBeLessThan(migrator.indexOf("pnpm install --frozen-lockfile")); + }); + + it("checks the same lock in both staging app build contexts", () => { + const caller = workflow.split(" build-and-push-cloud:")[1].split(" # Moves")[0]; + expect(caller).toContain("uses: ./.github/workflows/docker-cloud.yml"); + expect(caller).toContain("staging_artifact_base_url: ${{ inputs.staging_artifact_base_url || '' }}"); + expect(caller).toContain("staging_lock_sha256: ${{ inputs.staging_lock_sha256 || '' }}"); + const contexts = [workflow, cloudWorkflow].flatMap((source) => + source.split(" - name: Refresh lockfile for Docker build context").slice(1)); + expect(contexts).toHaveLength(2); + for (const context of contexts) { + const refresh = context.split(" - name:")[0]; + expect(refresh).toContain("STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }}"); + expect(refresh).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}"); + expect(refresh).toContain('if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then'); + const verification = refresh.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict'); + expect(verification).toBeGreaterThan(refresh.indexOf("pnpm install --resolution-only --ignore-scripts")); + expect(verification).toBeLessThan(refresh.indexOf('changed="$(git status --porcelain)"')); + } + }); +}); From fe13eadb8afd9cfb314b5d3dc134b19b924508bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:02:29 -0500 Subject: [PATCH 2/2] test(runner): serve goal reads while holding passive completion notices Co-Authored-By: Paperclip --- .../src/bin/fake-codex-app-server.rs | 111 ++++++++++-------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index 3490a4c1b0..18059de632 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -1619,56 +1619,71 @@ fn run() -> Result<(), Box> { }))?; } if emit_post_completion_passive_statuses { - if let Some(gate) = post_completion_notification_gate.as_ref() { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while !gate.is_file() { - if std::time::Instant::now() >= deadline { - return Err( - "post-completion notification gate timed out".into() - ); + let gate = post_completion_notification_gate.clone(); + let thread_id = state.thread_id.clone(); + let tail_turn_id = provider_turn_id.clone(); + // Goal reconciliation reads must remain responsive while + // the test holds back the post-terminal passive notices. + thread::spawn(move || { + let result = (|| -> io::Result<()> { + if let Some(gate) = gate.as_ref() { + let deadline = + std::time::Instant::now() + Duration::from_secs(5); + while !gate.is_file() { + if std::time::Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "post-completion notification gate timed out", + )); + } + thread::sleep(Duration::from_millis(1)); + } } - thread::sleep(Duration::from_millis(1)); + for notification in [ + json!({ + "method": "deprecationNotice", + "params": {"summary": "A provider setting is deprecated", "details": null} + }), + json!({ + "method": "remoteControl/status/changed", + "params": {"status": "disabled", "environmentId": null} + }), + json!({ + "method": "mcpServer/startupStatus/updated", + "params": {"name": "codex_apps", "status": "ready", "error": null} + }), + json!({ + "method": "account/rateLimits/updated", + "params": {"rateLimits": {}} + }), + json!({ + "method": "rawResponseItem/completed", + "params": {"threadId": thread_id, "turnId": tail_turn_id, "item": {"id": "raw-tail", "type": "reasoning"}} + }), + json!({ + "method": "rawResponse/completed", + "params": {"threadId": thread_id, "turnId": tail_turn_id, "response": {"id": "response-tail"}} + }), + json!({ + "method": "thread/goal/updated", + "params": {"threadId": thread_id, "goal": "finish the turn"} + }), + json!({ + "method": "thread/goal/cleared", + "params": {"threadId": thread_id} + }), + ] { + send(notification)?; + } + if let Some(gate) = gate.as_ref() { + fs::write(gate.with_extension("emitted"), b"emitted")?; + } + Ok(()) + })(); + if let Err(error) = result { + eprintln!("post-completion passive tail failed: {error}"); } - } - for notification in [ - json!({ - "method": "deprecationNotice", - "params": {"summary": "A provider setting is deprecated", "details": null} - }), - json!({ - "method": "remoteControl/status/changed", - "params": {"status": "disabled", "environmentId": null} - }), - json!({ - "method": "mcpServer/startupStatus/updated", - "params": {"name": "codex_apps", "status": "ready", "error": null} - }), - json!({ - "method": "account/rateLimits/updated", - "params": {"rateLimits": {}} - }), - json!({ - "method": "rawResponseItem/completed", - "params": {"threadId": state.thread_id, "turnId": provider_turn_id, "item": {"id": "raw-tail", "type": "reasoning"}} - }), - json!({ - "method": "rawResponse/completed", - "params": {"threadId": state.thread_id, "turnId": provider_turn_id, "response": {"id": "response-tail"}} - }), - json!({ - "method": "thread/goal/updated", - "params": {"threadId": state.thread_id, "goal": "finish the turn"} - }), - json!({ - "method": "thread/goal/cleared", - "params": {"threadId": state.thread_id} - }), - ] { - send(notification)?; - } - if let Some(gate) = post_completion_notification_gate.as_ref() { - fs::write(gate.with_extension("emitted"), b"emitted")?; - } + }); } if emit_post_completion_foreign_turn { let gate = post_completion_notification_gate.clone();