Files
PaperClipAI/server/src/services
DottaandClaude Opus 5.5 86cc3e3a11 fix(workspaces): save remote workspaces that contain outside symlinks (#20569)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents on a remote sandbox (for example Daytona) save their
workspace after each run, and recovery retries a failed save
> - The save refused the whole workspace when one symlink was absolute
or pointed out of the workspace
> - Ordinary agent work makes such links: a headless Chromium profile
links `SingletonSocket` by absolute path into `/tmp`
> - Recovery then spent its full budget on the same refusal, handed the
hold to the board, and the task stayed blocked
> - This pull request keeps confined links, records and omits outside
links, and retries saves that an older policy exhausted
> - The benefit is that these saves succeed and blocked tasks continue
without a person

## Linked Issues or Issue Description

**What happened?**
On staging, a native run (OpenCode on `paperclip_runner`, Daytona
sandbox) built Storybook and drove Chromium with
`--user-data-dir=<workspace>/.review-chrome`. Chromium wrote
`.review-chrome/SingletonSocket ->
/tmp/.org.chromium.Chromium.*/SingletonSocket`. The save failed three
times with `remote_workspace_archive_unsafe_symlink`
(`native_workspace_sync_out_retry_exhausted`). System recovery then
tried six more times. It recorded each failure as `export_interrupted`
with cause `provider_error`, became `exhausted`, and gave the hold to
the board. The task and its parent stayed blocked.

**Expected behavior**
The save succeeds for an ordinary workspace that contains symlinks.
Links inside the workspace stay links. Absolute and escaping links are
dropped and recorded, and are never followed. Holds that an earlier
release exhausted recover automatically after deploy.

**Steps to reproduce**
1. Create a workspace with `node_modules/.bin/tool -> ../tool/cli.js`,
an absolute link to a file outside the workspace, and a link `escape ->
../outside/file`.
2. Capture it with `captureRemoteWorkspaceArchive` (or the serialized
sandbox helper).
3. Before this change, the capture fails with
`remote_workspace_archive_unsafe_symlink`.

## What Changed

- `remote-workspace-snapshot-archive.ts`: capture records an absolute or
escaping symlink in the manifest `excluded` list and omits it, instead
of failing the save. It never follows a link. Confined links are kept.
Restore validation is unchanged and still refuses an archive that names
an outside link.
- Added `REMOTE_WORKSPACE_CAPTURE_POLICY_VERSION = 2`. Each new system
recovery generation records `capturePolicyVersion`. Generations without
the field count as version 1.
- New `workspace-recovery-capture-policy.ts`: an exhausted save whose
last failure was in `preserve_files` (`export_interrupted`) under an
older capture policy gets one new bounded generation. Restore refusals
(`archive_unsafe`) are not re-armed, because restore validation is
unchanged. It shares the run-over guard (`sourceRunProvablyOver`) with
the stop-proof re-arm. It writes a `capturePolicyRecovery` marker and
`issue.workspace_recovery_rearmed` activity with reason
`capture_policy_changed`. The new generation has the current version, so
the rule cannot repeat for it.
- The recovery worker discovers these holds
(`capturePolicyRearmPendingCondition` in `nextWorkspaceRecoveryAt` and
the obligations query).
- Docs: `doc/native-workspace-finalization-recovery.md` (symlink policy)
and `doc/execution-semantics.md` (capture-policy re-arm).

Previous-release story: archives saved by the previous release never
contain outside links, and restore of them is unchanged. Recovery state
saved by the previous release has no `capturePolicyVersion`; it is read
as version 1 and, if exhausted in `preserve_files`, is re-armed once on
the first worker pass after deploy. No migration is needed. A capture
already in flight at the cutover uses the helper that its operation
installed; the next attempt installs the new helper.

## Verification

Red before the fix (same tests, unfixed code):

- `remote-workspace-snapshot-archive.test.ts` "keeps confined links and
drops outside links" (local and serialized helper): failed with
`remote_workspace_archive_unsafe_symlink`.
- `remote-workspace-transport.test.ts` "saves a workspace with outside
links through the sandbox helper" (split and Daytona one-shot output):
failed with `remote_workspace_archive_unsafe_symlink`.
- `workspace-recovery-capture-policy.test.ts` "retries once under the
new policy": failed, because the worker did not discover the exhausted
hold (`nextWorkspaceRecoveryAt` returned null).

Green after the fix:

- `npx vitest run --root server
src/services/remote-workspace-snapshot-archive.test.ts
src/services/remote-workspace-transport.test.ts
src/services/workspace-recovery-capture-policy.test.ts`: 271 passed.
- The recovery test uses the exact staging evidence shape (6 attempts,
`export_interrupted`, `provider_error`, board owner, no policy version).
It ends `complete` and `recovered`, saves a snapshot, and leaves the
source untouched.
- Safety cases: the archive does not contain the outside bytes; restore
refuses a hand-made archive with an escaping link; a hold exhausted
under the current policy is not re-armed and no sandbox resume is
attempted.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify`: see CI for the broad selection.

## Risks

- A tracked symlink that is absolute or escaping is now omitted. After
restore it shows as a deletion in `git status`. Before, the whole save
failed.
- On deploy, every hold exhausted in `preserve_files` with
`export_interrupted` gets one more bounded recovery generation,
including holds that failed for provider reasons. This is one generation
per hold per policy version.
- The manifest exclusion label stays
`paperclip.remote-workspace-exclusions.v1`. Workspaces that the old
policy accepted produce the same manifest, so unchanged-snapshot reuse
still works.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:51:29 -06:00
..
…
…
…
…