fix: recover legacy sandbox cwd and preserve staging lock verification

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 15:40:10 -05:00
parent 74ed77e21d
commit ebfdbfb0b1
6 changed files with 60 additions and 3 deletions

View File

@ -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

View File

@ -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

View File

@ -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:<full SHA>` through

View File

@ -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];

View File

@ -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<string, any>;
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);

View File

@ -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 } : {}),
};