Files
Barış ÖZDEMİR bf14f803d5 fix(ssh): transport project repositories as their own git checkouts (#14782)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A project can attach more than one repository. The task workspace
keeps the selected repository at its root and puts the other project
repositories under `.paperclip-repositories/<name>-<key>`, each with its
own `.git`
> - Agents can run on an SSH execution environment. Paperclip copies the
task workspace to the remote host before the run and restores it after
the run
> - The SSH copy excludes `.git` at every depth, but the restore
baseline excludes it only at the workspace root
> - So the other project repositories reach the remote host without Git,
and the restore then deletes their `.git` directories on the Paperclip
host
> - The next run of the same task fails during workspace setup, and the
agent cannot commit to those repositories on the remote host
> - This pull request transports each project repository as a Git
workspace of its own, the same way the sandbox path already handles them
> - The benefit is that multi-repository projects work on SSH
environments across consecutive runs

## Linked Issues or Issue Description

Refs #11632 (SSH workspace transfer exclude list). Related SSH workspace
PRs: #14233, #14428, #14472. I found no issue or PR for this bug.

**What happened?**

A project has two repositories and its agent runs on an SSH environment.
After the first run, the second repository under
`.paperclip-repositories/` has no `.git` directory on the Paperclip
host. The next run of the same task fails during setup with `Managed
workspace path "…/.paperclip-repositories/<repo>" already exists but is
not a git checkout.` On the remote host, `git` inside that repository
resolves to the parent repository.

**Expected behavior**

Each project repository reaches the remote host as a Git checkout with
its local changes. Remote commits and edits come back after the run. The
next run of the same task starts normally.

**Steps to reproduce**

1. Create a project with two repositories.
2. Configure an SSH execution environment and make it the agent's
default environment.
3. Assign a task to the agent and let it run once.
4. Look at `.paperclip-repositories/<repo>` in the task workspace:
`.git` is gone.
5. Wake the agent on the same task again: the run fails with
`setup_failed`.

**Paperclip version or commit**

Reproduced on `v2026.916.1` and on `master` (`5edf55d73`).

**Deployment mode**

Self-hosted (Docker), authenticated, with an SSH execution environment.

## What Changed

- `ssh.ts`: `prepareWorkspaceForSshExecution` lists the project
repositories under `.paperclip-repositories/`. It applies the discovery
rules of `readGitWorkspaceSnapshot`: each entry must be a directory with
a valid name and must be a Git repository root, else the prepare step
fails before any transfer.
- `ssh.ts`: the anchor copy leaves `.paperclip-repositories/` out. Each
project repository then gets the same import, sync, and deleted-path
steps as the anchor. The remote anchor repository ignores
`/.paperclip-repositories/`, as the local checkout does.
- `ssh.ts`: `prepareWorkspaceForSshExecution` returns the transported
repositories (the field is present only when there are repositories).
`restoreWorkspaceFromSshExecution` accepts them with their baselines. It
validates each path and baseline first, then restores the repositories
before the anchor and stops at the first failure, as the sandbox restore
does.
- `remote-managed-runtime.ts`: the anchor baseline excludes
`.paperclip-repositories/`, and each project repository gets its own
baseline for the restore merge.
- `ssh-fixture.test.ts`: regression tests for two consecutive managed
runs and for the direct restore path, on a workspace with a project
repository (commits, dirty edits, and a deleted file). Two tests for the
new validation.
-
`docs/guides/board-operator/execution-workspaces-and-runtime-services.md`:
one line about project repositories in the SSH round trip.

## Verification

- The new regression test fails on `master` (`expected 'backend
initial\n?? ../\n' to contain 'frontend initial'`) and passes with this
change.
- `PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB=1 npx vitest run
packages/adapter-utils/src/ssh-fixture.test.ts
packages/adapter-utils/src/remote-managed-runtime.test.ts`: 32 passed,
with the sshd fixture running.
- `tsc --noEmit` passes for `packages/adapter-utils` and `server`, and
`pnpm -r typecheck` passes for the other workspaces. The Rust step of
`@paperclipai/paperclip-runner` did not run locally because `cargo` is
not installed.
- `node ./scripts/check-no-git-push.mjs` and `pnpm
check:module-boundaries` pass.
- `pnpm test:run` did not complete locally. Before it stopped, 5 tests
failed: 2 in `server/src/__tests__/workspace-runtime.test.ts` and 3 in
`server/src/__tests__/company-skills-service.test.ts`. The same 5 tests
also fail on the base commit `5edf55d73` without this change. CI runs
the full suite.
- `pnpm build` passes for all workspaces except
`@paperclipai/paperclip-runner` and `server`, because their build
compiles the Rust runner binary and `cargo` is not installed. `tsc
--noEmit` passes for `server`.
- Manual test on a self-hosted `v2026.916.1` instance with the same
change applied: a project with two repositories and an SSH environment.
Two runs on the same task passed. After each run, the second repository
keeps its `.git` on the host. On the remote host it is a Git checkout,
and the remote anchor ignores it.

## Risks

- Low risk. Workspaces without `.paperclip-repositories/` take the same
path as before, and the return value is unchanged for them.
- A workspace with an invalid entry under `.paperclip-repositories/` now
fails the SSH prepare step. The sandbox path already rejects such
entries.
- If one repository fails to restore, the restore stops, as in the
sandbox path. The remote run directory keeps the agent's work.
- Each project repository adds one bundle import and one restore per
run. The time grows with the number and size of the repositories.
- Out of scope: other nested `.git` directories (for example a vendored
checkout inside a repository) keep the existing SSH behavior.

## Model Used

- Provider and model: Anthropic Claude Opus 5.5 (`claude-opus-5-5`), in
Claude Code.
- Capabilities: extended thinking, tool use, and code execution. The
context window size was not recorded.
- Use: the model investigated the bug, wrote the change and the tests,
and ran the checks. A separate Claude Code agent reviewed the diff. The
author reviewed the change. The manual test ran on the author's
self-hosted instance.

## 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
2026-10-05 22:21:00 -07:00
..

@paperclipai/adapter-utils

Shared utilities for Paperclip adapters: process spawning, environment injection, sandbox/SSH transport, workspace sync, and the round-trip helpers that move code between the local execution-workspace cwd and wherever the agent actually runs.

For the adapter-author guide see docs/adapters/creating-an-adapter.md and the in-repo notes at packages/adapters/AUTHORING.md.

Sandbox bridge deadlines

Command-managed bridge control operations (queue reads, input delivery, setup, and cleanup) have a host-enforced deadline of at most 30 seconds per shell command. A shorter configured timeout still applies. These operations cannot inherit the agent run's hours-long lifetime or wait forever on a provider that ignores its timeout. Long-lived agent session commands keep their own limits.

A control timeout reports a transport failure. It does not prove that the remote command stopped and does not replay an uncertain write. The process session closes failed input delivery and uses its existing shutdown path; normal execution settlement must still verify termination.

No-remote-git contract

The local execution-workspace cwd is the only persistence boundary across runs. No adapter may depend on a git remote for cross-run state.

Adapters that run the agent on a different host should use the SSH round-trip helpers in src/ssh.ts:

  • prepareWorkspaceForSshExecution({ spec, localDir, remoteDir }) — bundles the local cwd (tracked files, dirty edits, untracked additions, and the git history needed to reconstruct it) to remoteDir before the run starts. Runs with no git remote configured.
  • restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... }) — syncs the remote cwd back into localDir after the run, including any new commits the agent created. Also runs with no git remote configured.

prepareRemoteManagedRuntime in src/remote-managed-runtime.ts wraps both calls for adapters that want a per-run remote workspace and an automatic restoreWorkspace() finally hook.

The invariant is pinned by the no-remote-git contract case in src/ssh-fixture.test.ts, which asserts that a remote-only commit propagates to the local worktree through the prepare → restore round-trip with no git remote configured at any point. Do not regress that test.