From 4c8d92f086c41159a03673fe0fe7b82056e5ae3f Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 29 Jul 2026 12:26:07 -0700 Subject: [PATCH 01/32] fix(ui): leave agent detail after termination (#10451) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The agent detail page lets operators inspect one agent and run lifecycle actions from that context > - Terminated agents are removed from the normal active-agent surface, so the current detail route may no longer be fetchable after termination > - Before this change, terminating an agent from its detail page invalidated agent queries while leaving the browser on the now-stale detail route > - That refetch could surface an "Agent not found" error even though the terminate action itself succeeded > - The shared action button already owns the terminate mutation, so it can notify detail-page callers when termination succeeds > - This pull request redirects the detail page back to the agents list after a successful terminate action > - The benefit is that operators land on a valid route and the Back button does not return them to the stale terminated-agent detail route ## Linked Issues or Issue Description No direct public GitHub issue or PR was found for this detail-page termination flow. ### What happened? After terminating an agent from its detail page, the UI could remain on that agent's detail route and show an "Agent not found" error after query invalidation/refetch. ### Expected behavior Once termination succeeds, the operator should leave the now-stale detail page and land on a valid agents view. ### Steps to reproduce 1. Open a non-built-in agent detail page. 2. Use the overflow actions menu to terminate the agent. 3. Observe the post-termination route/error state. ### Paperclip version or commit Current `master` before this PR. ### Deployment mode Browser UI behavior, independent of a specific deployment mode. Duplicate search: searched public GitHub issues and PRs for `agent not found terminate`, `terminate agent detail`, and `Agent not found`; no direct duplicate or viable in-flight PR was found. ## What Changed - Added an optional `onTerminateSuccess` callback to `AgentActionButtons`, fired only after the shared terminate mutation succeeds. - Wired `AgentDetail` to replace-navigate to `/agents/all` after successful termination. - Extended `AgentActionButtons` coverage for the terminate success path, including API args, callback payload, and query invalidations. ## Verification - `corepack pnpm exec vitest run ui/src/components/AgentActionButtons.test.tsx` - `corepack pnpm check:token-gates` - `git diff --check origin/master..HEAD` - Local diff scan for obvious tokens, credential filenames, and email addresses found no matches. ## Risks Low risk. The new callback is optional, only fires for successful terminate actions, and preserves existing behavior for other `AgentActionButtons` callers. ## Model Used OpenAI Codex, GPT-5-based coding agent (`gpt-5`), tool use enabled for repository inspection, editing, local verification, and GitHub CLI operations. Context window details were not exposed by the runtime. ## 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 Co-authored-by: Paperclip --- ui/src/components/AgentActionButtons.test.tsx | 36 +++++++++++++++++-- ui/src/components/AgentActionButtons.tsx | 6 ++++ ui/src/pages/AgentDetail.tsx | 1 + 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index 6a156c0ceed..507530bdbdd 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -107,6 +107,7 @@ describe("AgentActionButtons", () => { mockAgentsApi.clearError.mockResolvedValue(makeAgent({ status: "idle" })); mockAgentsApi.pause.mockResolvedValue(makeAgent({ status: "paused" })); mockAgentsApi.resume.mockResolvedValue(makeAgent({ status: "idle" })); + mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated" })); mockAgentsApi.invoke.mockResolvedValue({ id: "run-1" }); mockAgentsApi.resetSession.mockResolvedValue(undefined); }); @@ -124,11 +125,11 @@ describe("AgentActionButtons", () => { vi.clearAllMocks(); }); - function render(agent: Agent) { + function render(agent: Agent, props: Partial> = {}) { root = createRoot(container); root.render( - + , ); } @@ -175,4 +176,33 @@ describe("AgentActionButtons", () => { expect(container.textContent).toContain("Pause"); expect(container.textContent).not.toContain("Clear error"); }); + + it("calls the terminate success handler after terminating an agent", async () => { + const onTerminateSuccess = vi.fn(); + render(makeAgent(), { onTerminateSuccess }); + await flushReact(); + + await act(async () => { + container.querySelector('[aria-label="Open actions for Alpha Agent"]')?.click(); + }); + await flushReact(); + + const terminateButton = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Terminate")); + expect(terminateButton).toBeTruthy(); + + await act(async () => { + terminateButton?.click(); + }); + await flushReact(); + + expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1"); + expect(onTerminateSuccess).toHaveBeenCalledWith(expect.objectContaining({ + id: "agent-1", + status: "terminated", + })); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] }); + }); }); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index dc13464b061..48e444ca923 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -165,6 +165,7 @@ export function AgentActionButtons({ workActionsDisabledReason, navigateToRunOnInvoke = true, onActionError, + onTerminateSuccess, pauseConfirm, hideTerminate = false, children, @@ -193,6 +194,8 @@ export function AgentActionButtons({ * omitted, failures surface as toasts (used by the list view). */ onActionError?: (message: string | null) => void; + /** Called after termination succeeds so callers can leave now-hidden detail routes. */ + onTerminateSuccess?: (agent: Agent) => void; /** Extra content rendered just before the overflow menu (e.g. live-run link). */ children?: React.ReactNode; className?: string; @@ -246,6 +249,9 @@ export function AgentActionButtons({ onSuccess: (data, action) => { onActionError?.(null); invalidateAgent(); + if (action === "terminate") { + onTerminateSuccess?.(data as Agent); + } if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) { navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`); } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index e97c56c0719..1cee14ad96b 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1157,6 +1157,7 @@ export function AgentDetail() { workActionsDisabled={hasInvalidOrgChain} workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" onActionError={setActionError} + onTerminateSuccess={() => navigate("/agents/all", { replace: true })} hideTerminate={Boolean(builtInState)} pauseConfirm={ builtInState From d6e235cbcfb8fe292d55388b14b209dc77225190 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 29 Jul 2026 12:40:16 -0700 Subject: [PATCH 02/32] perf(sandbox-providers): drop nvm sourcing from exec wrappers (#10443) ## Thinking Path > - Paperclip keeps agent work on a controlled execution plane. > - Sandbox exec wrappers run on the hot path for agent commands. > - The current change removes the explicit `nvm.sh` load step from those wrappers. > - The sandbox image already restores PATH through profile startup. > - This pull request keeps profile sourcing where the wrapper still needs it and drops only the `nvm.sh` load step. > - The result is a smaller command path with the same node and agent CLI resolution. ## Linked Issues or Issue Description No public GitHub issue exists for this change. Problem: The sandbox exec wrappers spent extra time sourcing `nvm.sh` before each command. The sandbox image already restores PATH in `/etc/profile.d/00-restore-env.sh`, so that explicit `nvm.sh` work was redundant. Proposed solution: Remove the `nvm.sh` source step from all six wrappers. Keep the profile sourcing that the provider still needs for PATH setup. Alternatives considered: Keep the existing shell setup and accept the launch cost. That keeps the current behavior, but it leaves the hot path slower than needed. Roadmap alignment: This change keeps the sandbox command path small and predictable. It does not change the adapter contract or the node resolution rules. ## What Changed - Removed `nvm.sh` sourcing from all six sandbox exec wrappers. - Kept profile sourcing where the provider still needs it for PATH setup. - Switched Modal to a non-login shell because the script now sources profiles itself. - Updated wrapper tests to assert that built commands do not source `nvm.sh`. ## Verification - Local TypeScript typecheck passed in each changed package. - Focused provider tests passed for Daytona, E2B, Modal, exe-dev, Cloudflare bridge, and adapter-utils. - One Daytona test failure is pre-existing and unrelated to this change. ## Risks - This change alters shell startup for sandbox exec paths. - A provider that depends on implicit shell setup may need a follow-up. - The current tests cover command shape, but they do not cover every runtime shell path. ## Model Used OpenAI Codex, GPT-5, tool use. ## 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 or instance-local Paperclip issues or links - [x] My branch name describes the change 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 --------- Co-authored-by: Paperclip --- .../adapter-utils/src/ssh-fixture.test.ts | 43 +++++++++++ packages/adapter-utils/src/ssh.ts | 34 ++++++--- .../sandbox-providers/SANDBOX-REQUIREMENTS.md | 70 ++++++++++++++++++ .../bridge-template/src/exec.test.ts | 3 + .../cloudflare/bridge-template/src/exec.ts | 5 +- .../daytona/src/plugin.test.ts | 72 +++++++------------ .../sandbox-providers/daytona/src/plugin.ts | 51 ++++++------- .../sandbox-providers/e2b/src/plugin.test.ts | 3 + .../sandbox-providers/e2b/src/plugin.ts | 16 ++--- .../exe-dev/src/plugin.test.ts | 8 ++- .../sandbox-providers/exe-dev/src/plugin.ts | 5 +- .../modal/src/plugin.test.ts | 7 +- .../sandbox-providers/modal/src/plugin.ts | 16 ++--- 13 files changed, 225 insertions(+), 108 deletions(-) create mode 100644 packages/plugins/sandbox-providers/SANDBOX-REQUIREMENTS.md diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index ba2da73222b..0b8a45a0bf4 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -170,6 +170,49 @@ describe("ssh env-lab fixture", () => { await stopSshEnvLabFixture(statePath); }, SSH_FIXTURE_TEST_TIMEOUT_MS); + it("builds a remote script that sources login profiles but no nvm", async () => { + const target = await buildSshSpawnTarget({ + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + command: "node", + args: ["--version"], + env: { FOO: "bar" }, + }); + + // The remote script rides the last ssh argument. The SSH target is an + // operator-configured host that can expose `node` only through a login + // profile, so the wrapper sources the profiles. It no longer sources + // `nvm.sh`; a profile that adds nvm still runs. + const remoteScript = String(target.args.at(-1) ?? ""); + expect(remoteScript).not.toContain("nvm.sh"); + expect(remoteScript).not.toContain("NVM_DIR"); + // Source /etc/profile so a host that exposes the PATH through + // /etc/profile.d scripts still resolves node and the agent CLI. + expect(remoteScript).toContain("/etc/profile"); + expect(remoteScript).toContain(".profile"); + expect(remoteScript).toContain(".bash_profile"); + expect(remoteScript).toContain(".zprofile"); + // Fall back to .bashrc when no .bash_profile exists, so a host that adds + // nvm in .bashrc still resolves node under a non-login SSH command. + expect(remoteScript).toContain(".bashrc"); + // The last ssh argument wraps the script as `sh -c '...'`, so the inner + // quotes are escaped. Assert the command still runs: cd, env, and the argv. + expect(remoteScript).toContain("cd "); + expect(remoteScript).toContain("/srv/paperclip/workspace"); + expect(remoteScript).toContain("exec env "); + expect(remoteScript).toContain("node"); + expect(remoteScript).toContain("--version"); + await target.cleanup(); + }); + it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => { await expect( buildSshSpawnTarget({ diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index d3efffd7252..b2aa909c9a8 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -1166,14 +1166,22 @@ export async function runSshCommand( } } - // Mirror buildSshSpawnTarget: source login profiles first, then run - // `env KEY=VAL cmd` so user-supplied identity overrides win over anything - // a profile re-exports. Without this, a remote profile that resets HOME - // / NVM_DIR / etc. would silently undo the explicit env passed in here. + // Mirror buildSshSpawnTarget: source the login profiles first, then run + // `env KEY=VAL cmd` so user-supplied identity overrides win over anything a + // profile re-exports. The SSH target is an operator-configured host, not a + // Paperclip sandbox image, so it can expose `node` or an agent CLI only + // through a login profile; a non-login SSH command would miss that PATH. + // Source `/etc/profile` first so a host that exposes the PATH through + // `/etc/profile.d` scripts still resolves node and the agent CLI. + // The script no longer sources `nvm.sh`; a profile that adds nvm still runs. + // .bash_profile typically sources .bashrc itself; only source .bashrc + // directly when no .bash_profile exists, so a host that adds nvm in + // .bashrc still resolves node without a double-run of the setup. const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`); const remoteScript = [ + 'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi', 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi', - 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi', 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi', envArgs.length > 0 ? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}` @@ -1223,12 +1231,22 @@ export async function buildSshSpawnTarget(input: { .filter((entry): entry is [string, string] => typeof entry[1] === "string") .map(([key, value]) => `${key}=${shellQuote(value)}`); const remoteCommandParts = [shellQuote(input.command), ...input.args.map((arg) => shellQuote(arg))].join(" "); + // Source the login profiles first, then run `env KEY=VAL cmd` so + // user-supplied identity overrides win over anything a profile re-exports. + // The SSH target is an operator-configured host, not a Paperclip sandbox + // image, so it can expose `node` or an agent CLI only through a login + // profile; a non-login SSH command would miss that PATH. Source + // `/etc/profile` first so a host that exposes the PATH through + // `/etc/profile.d` scripts still resolves node and the agent CLI. The script + // no longer sources `nvm.sh`; a profile that adds nvm still runs. + // .bash_profile typically sources .bashrc itself; only source .bashrc + // directly when no .bash_profile exists, so a host that adds nvm in + // .bashrc still resolves node without a double-run of the setup. const remoteScript = [ + 'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi', 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi', - 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi', 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi', - 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"', - '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true', `cd ${shellQuote(input.spec.remoteCwd)}`, envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${remoteCommandParts}` diff --git a/packages/plugins/sandbox-providers/SANDBOX-REQUIREMENTS.md b/packages/plugins/sandbox-providers/SANDBOX-REQUIREMENTS.md new file mode 100644 index 00000000000..1d762085a3a --- /dev/null +++ b/packages/plugins/sandbox-providers/SANDBOX-REQUIREMENTS.md @@ -0,0 +1,70 @@ +# Sandbox Runtime Requirements + +This document states the sandbox environment as a contract. The sandbox owner +must meet this contract. The Paperclip runtime does not build the environment at +exec time. The environment is a requirement, not a build step. + +This document states requirements. It does not state build steps. + +## Required on PATH + +- `node` must be installed and on the PATH. +- Each agent CLI that the run uses must be installed and on the PATH. The set of + agent CLIs includes `claude`, `codex`, `gemini`, and similar CLIs. +- The owner installs only the CLIs that the run uses. The owner does not need to + install a CLI that no run uses. + +## Runtime dependencies + +The sandbox execution and synchronization paths need more than `node` and the +agent CLIs. The owner must also supply these: + +- A POSIX shell as `sh`, normally `/bin/sh`. The runtime runs each command with + `sh -c