From ebfdbfb0b101023426f12a18388302926e6f969b Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 15:40:10 -0500 Subject: [PATCH] fix: recover legacy sandbox cwd and preserve staging lock verification Co-Authored-By: Paperclip --- .github/workflows/docker-cloud.yml | 14 ++++++++++++++ .github/workflows/docker.yml | 3 +++ doc/sandbox-work-folders.md | 5 +++++ .../src/__tests__/docker-build-stamp.test.ts | 7 ++++++- .../__tests__/legacy-sandbox-session.test.ts | 19 +++++++++++++++++++ server/src/services/legacy-sandbox-session.ts | 15 +++++++++++++-- 6 files changed, 60 insertions(+), 3 deletions(-) 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 4656225960..d74f75fd1d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -427,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/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 6a43b3b3b9..b0a25abb89 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -105,6 +105,9 @@ workspace, environment, provider sandbox, working directory, and conversation all match. An explicit conflicting identity is never overwritten. Local and SSH session matching remain separate; a replacement sandbox cannot inherit a conversation merely because its working-directory path is the same. +Old codecs that recorded the host checkout path are translated to the sandbox +path only when both the saved workspace and its local realization prove that +exact host path and project-workspace ID. Arbitrary path changes still reset. Codex configuration refresh replaces only the managed auth/config/skills entries; it preserves the sandbox's rollout files and SQLite state, including WAL files. Those provider-session files stay outside shared work-folder collections. @@ -584,6 +587,8 @@ that digest before installing dependencies, failing if registry resolution has changed. Transfer those artifacts to the staging bucket using conditional creates, publishing the manifest last. Do not create GitHub releases or publish npm packages for this flow. +The reusable Cloud build workflow receives those staging inputs explicitly; +splitting the workflow must not drop the app/migrator dependency-integrity gate. Cloud enables this lane only in staging through `CLOUD_HARNESS_STAGING_ARTIFACT_BASE_URL`. Resolve `preview:` through diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 61d7a4e017..26a4737957 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -89,7 +89,12 @@ describe("staging dependency integrity", () => { }); it("checks the same lock in both staging app build contexts", () => { - const contexts = workflow.split(" - name: Refresh lockfile for Docker build context").slice(1); + 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]; diff --git a/server/src/__tests__/legacy-sandbox-session.test.ts b/server/src/__tests__/legacy-sandbox-session.test.ts index 0296e2ede1..57a47cf5c4 100644 --- a/server/src/__tests__/legacy-sandbox-session.test.ts +++ b/server/src/__tests__/legacy-sandbox-session.test.ts @@ -89,6 +89,25 @@ describe("legacy sandbox conversation persistence", () => { expect(recoverLegacySandboxSession(input)).toBe(input.params); } }); + it.each(["codex_local", "claude_local"])("recovers %s host cwd only from the exact recorded workspace", (adapterType) => { + const input = fixture(); input.adapterType = adapterType; + input.params = { ...input.params, cwd: "/host/project", workspaceId: "project-workspace" }; + const context = input.previousRun!.contextSnapshot as Record; + context.paperclipWorkspace = { cwd: "/host/project", workspaceId: "project-workspace" }; + context.paperclipEnvironment.workspaceRealization.local = { path: "/host/project", projectWorkspaceId: "project-workspace" }; + expect(recoverLegacySandboxSession(input)).toMatchObject({ cwd: target.remoteCwd, remoteExecution: adapterExecutionTargetSessionIdentity(target) }); + for (const mutate of [ + (copy: typeof input) => { copy.params!.cwd = "/another/project"; }, + (copy: typeof input) => { copy.params!.workspaceId = "another-workspace"; }, + (copy: typeof input) => { delete (copy.previousRun!.contextSnapshot as any).paperclipWorkspace; }, + (copy: typeof input) => { (copy.previousRun!.contextSnapshot as any).paperclipEnvironment.workspaceRealization.local.path = "/another/project"; }, + (copy: typeof input) => { (copy.previousRun!.contextSnapshot as any).paperclipEnvironment.remoteCwd = "/another/remote"; }, + ]) { + const changed = structuredClone(input); mutate(changed); + expect(recoverLegacySandboxSession(changed)).toBe(changed.params); + } + }); + it("keeps legacy host-lease matching when no physical identity is available", () => { const oldTarget = { ...target, sandboxLeaseAcquisition: undefined }; const saved = adapterExecutionTargetSessionIdentity(oldTarget); diff --git a/server/src/services/legacy-sandbox-session.ts b/server/src/services/legacy-sandbox-session.ts index 55ea742246..8273ac6f84 100644 --- a/server/src/services/legacy-sandbox-session.ts +++ b/server/src/services/legacy-sandbox-session.ts @@ -36,6 +36,16 @@ export function recoverLegacySandboxSession(input: { const context = record(previous.contextSnapshot); const environment = record(context.paperclipEnvironment); const realization = record(environment.workspaceRealization); + const workspace = record(context.paperclipWorkspace); + const local = record(realization.local); + // Old CLI codecs persisted the host checkout path even when the provider + // conversation lived in the sandbox. Translate only the exact path and + // workspace recorded by the successful host run; never accept another path. + const savedHostCwd = typeof params.cwd === "string" && params.cwd.length > 0 + && params.cwd === workspace.cwd && params.cwd === local.path + && typeof params.workspaceId === "string" && params.workspaceId.length > 0 + && params.workspaceId === workspace.workspaceId + && params.workspaceId === local.projectWorkspaceId; if (previous.status !== "succeeded" || previous.companyId !== input.companyId || previous.agentId !== input.agentId || previous.responsibleUserId !== input.responsibleUserId @@ -45,13 +55,14 @@ export function recoverLegacySandboxSession(input: { || environment.driver !== "sandbox" || environment.id !== target.environmentId || realization.provider !== target.providerKey || realization.providerLeaseId !== target.sandboxLeaseAcquisition.providerLeaseId - || params.cwd !== target.remoteCwd || environment.remoteCwd !== params.cwd + || environment.remoteCwd !== target.remoteCwd + || (params.cwd !== target.remoteCwd && !savedHostCwd) || (params.remoteExecution != null && ( saved.leaseId !== environment.leaseId || saved.environmentId !== environment.id || saved.providerKey !== target.providerKey || saved.remoteCwd !== params.cwd ))) return params; return { - ...params, remoteExecution: adapterExecutionTargetSessionIdentity(target), + ...params, cwd: target.remoteCwd, remoteExecution: adapterExecutionTargetSessionIdentity(target), ...(input.adapterType === "claude_local" && !params.mcpServerIdentity ? { legacyPlatformMcpSession: true } : {}), };