fix(server): skip host provisionCommand for sandbox-driver environments (#11626)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The run orchestrator prepares an execution environment before an
agent starts
> - A sandbox driver creates a remote folder before the adapter uploads
repository content
> - The orchestrator ran the host `provisionCommand` in that empty
folder
> - The command failed with exit 127 before the adapter could run its
`stage.sync` step
> - This pull request skips host provisioning for sandbox drivers and
keeps the existing local and SSH behavior
> - The benefit is that sandbox runs reach the adapter sync step without
an empty-folder setup failure

## Linked Issues or Issue Description

**What happened?**

A sandbox environment ran the host `provisionCommand` before the adapter
uploaded repository content. The command ran in an empty remote folder
and failed with exit 127.

**Expected behavior**

The orchestrator should skip host provisioning for a sandbox driver. The
adapter should upload the provisioned tree during its `stage.sync` step.

**Steps to reproduce**

1. Configure an environment with the `sandbox` driver and a host
`provisionCommand`.
2. Start a run that uses this environment.
3. Observe that the command runs in the empty sandbox folder and the run
fails with `setup_failed`.

**Paperclip version or commit**

Reproduced on the current `master` commit before this change.

**Deployment mode**

Built from source with a sandbox environment.

**Installation method**

Built from source with pnpm.

**Agent adapter(s) involved**

Not adapter-specific (core bug). The sandbox adapter syncs the tree
after environment setup.

**Database mode**

Not database-related.

**Additional context**

Related context:
[#11091](https://github.com/paperclipai/paperclip/pull/11091) changes
provision behavior for reused workspaces. This pull request covers the
separate sandbox ordering failure.

## What Changed

- Skip the orchestrator provision step when `environment.driver` is
`sandbox`.
- Keep the existing skip for `local` and the provision step for `ssh`.
- Log one info message when a sandbox skip drops a present command.
- Keep the existing `plugin` path because it has no `stage.sync` step
and runs against the host filesystem.
- Add tests for sandbox, local, SSH, plugin, logging, and provision
failures.

## Verification

- Run `./node_modules/.bin/vitest run
server/src/__tests__/environment-run-orchestrator.test.ts`.
- Confirm that the test run passes all 10 tests.
- Confirm that CI checks pass on this pull request.

## Risks

- Low risk. The change affects only the provision gate for sandbox
drivers.
- SSH and local behavior stays unchanged.
- The plugin driver stays on its current path.
- The new log line makes a sandbox skip visible to operators.

## Model Used

OpenAI, GPT-5, exact runtime model `gpt-5`, with tool use and code
review support. The implementation author used this model to inspect
code, edit source and tests, and run the targeted test suite.

## 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 (no exact duplicate found; related PR #11091 reviewed)
- [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 (no
documentation change applies to this internal gate correction)
- [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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-18 09:29:31 -07:00 committed by GitHub
parent 6b8e42168e
commit 0e9b03832d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 164 additions and 11 deletions

View File

@ -10,6 +10,7 @@ const mockBuildWorkspaceRealizationRequest = vi.hoisted(() => vi.fn());
const mockUpdateLeaseMetadata = vi.hoisted(() => vi.fn());
const mockUpdateExecutionWorkspace = vi.hoisted(() => vi.fn());
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockLoggerInfo = vi.hoisted(() => vi.fn());
vi.mock("../services/environment-execution-target.js", () => ({
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
@ -44,6 +45,15 @@ vi.mock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.mock("../middleware/logger.js", () => ({
logger: {
info: mockLoggerInfo,
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// ---------------------------------------------------------------------------
// Imports after mocks
// ---------------------------------------------------------------------------
@ -407,6 +417,84 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
});
it("runs a remote provision command after workspace realization when configured", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: "npm install -g @anthropic-ai/claude-code",
},
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
kind: "remote",
transport: "ssh",
remoteCwd: "/remote/workspace",
environmentId: "env-1",
leaseId: "lease-1",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/remote/workspace",
remoteWorkspacePath: "/remote/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
});
const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/remote/workspace",
metadata: {
workspaceRealization: {
version: 1,
transport: "ssh",
remote: { path: "/remote/workspace" },
},
},
}),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("ssh"),
}));
// The `ssh` driver runs the command on the remote host that shares the
// workspace path, so the configured provision command still runs there.
expect(runtime.execute).toHaveBeenCalledOnce();
expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({
environment: expect.objectContaining({ driver: "ssh" }),
lease: expect.objectContaining({ id: "lease-1" }),
command: "bash",
args: ["-lc", "npm install -g @anthropic-ai/claude-code"],
cwd: "/remote/workspace",
env: {
SHELL: "/bin/bash",
},
}));
// The sandbox skip log is specific to the sandbox driver; ssh stays quiet.
expect(mockLoggerInfo).not.toHaveBeenCalled();
});
it("skips the host provision command for a sandbox environment and logs the skip", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
@ -458,17 +546,59 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
environment: makeEnvironment("sandbox"),
}));
expect(runtime.execute).toHaveBeenCalledOnce();
expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({
environment: expect.objectContaining({ driver: "sandbox" }),
lease: expect.objectContaining({ id: "lease-1" }),
command: "bash",
args: ["-lc", "npm install -g @anthropic-ai/claude-code"],
cwd: "/remote/workspace",
env: {
SHELL: "/bin/bash",
// The sandbox receives the provisioned tree through the adapter stage.sync
// step, so the orchestrator must not run the host command in the sandbox.
expect(runtime.execute).not.toHaveBeenCalled();
// The skip is observable: exactly one log line records it with the driver.
expect(mockLoggerInfo).toHaveBeenCalledOnce();
expect(mockLoggerInfo).toHaveBeenCalledWith(
expect.objectContaining({ driver: "sandbox", environmentId: "env-1" }),
expect.stringContaining("Skip host provisionCommand"),
);
});
it("does not rerun the provision command during local environment realization", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: "npm install -g @anthropic-ai/claude-code",
},
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
kind: "local",
environmentId: "env-1",
leaseId: "lease-1",
});
const runtime = makeMockRuntime();
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("local"),
}));
// Local workspace provisioning already ran the command before realizeForRun.
expect(runtime.execute).not.toHaveBeenCalled();
// The sandbox skip log is specific to the sandbox driver; local stays quiet.
expect(mockLoggerInfo).not.toHaveBeenCalled();
});
it("runs project-level provision commands for ssh environments", async () => {
@ -588,7 +718,7 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await expect(orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("sandbox"),
environment: makeEnvironment("ssh"),
}))).rejects.toSatisfy(
(err: unknown) =>
err instanceof EnvironmentRunError &&

View File

@ -45,6 +45,7 @@ import {
import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { logActivity } from "./activity-log.js";
import { logger } from "../middleware/logger.js";
import { parseObject } from "../adapters/utils.js";
import type { RealizedExecutionWorkspace } from "./workspace-runtime.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
@ -420,7 +421,29 @@ export function environmentRunOrchestrator(
(typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0
? lease.metadata.remoteCwd.trim()
: executionWorkspace.cwd);
if (provisionCommand && environment.driver !== "local") {
// The host `provisionCommand` runs on the host worktree during the
// `workspace_provision` step, before the run reaches the environment.
// A `sandbox`-driver environment does not receive the repo tree here. The
// sandbox driver `realizeWorkspace` step only creates the remote folder.
// The adapter uploads the provisioned tree later, in its `stage.sync` step.
// So the host command must not run inside the still-empty sandbox; it fails
// there (exit 127). Skip the step for `sandbox`, and keep the existing skip
// for `local`. Keep the step for `ssh`, which runs the command on the
// remote host that shares the workspace path.
const driverSkipsHostProvision =
environment.driver === "local" || environment.driver === "sandbox";
if (provisionCommand && environment.driver === "sandbox") {
logger.info(
{
environmentId: environment.id,
driver: environment.driver,
issueId,
heartbeatRunId,
},
"Skip host provisionCommand for sandbox-driver environment; the adapter stage.sync step delivers the provisioned tree",
);
}
if (provisionCommand && !driverSkipsHostProvision) {
try {
const provisionResult = await environmentRuntime.execute({
environment,