From 0e9b03832d55307d07e29f979f3680b841ae2f42 Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Tue, 18 Aug 2026 09:29:31 -0700
Subject: [PATCH 01/39] 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
---
.../environment-run-orchestrator.test.ts | 150 ++++++++++++++++--
.../services/environment-run-orchestrator.ts | 25 ++-
2 files changed, 164 insertions(+), 11 deletions(-)
diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts
index 41c2640c1d8..01bb0442a9a 100644
--- a/server/src/__tests__/environment-run-orchestrator.test.ts
+++ b/server/src/__tests__/environment-run-orchestrator.test.ts
@@ -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 &&
diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts
index 975358ec82e..c907d4c177e 100644
--- a/server/src/services/environment-run-orchestrator.ts
+++ b/server/src/services/environment-run-orchestrator.ts
@@ -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,
From 9ea8143c870afe24fbc9de8ba65be66082fa20ad Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 18 Aug 2026 11:00:54 -0700
Subject: [PATCH 02/39] fix(adapter-utils): give sync-created merge commits a
deterministic git identity (#11637)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Runs execute in transported workspaces; at finalize, the host syncs
the sandbox git history back into the local worktree
> - When both sides advanced, `integrateImportedGitHead` reconciles them
with `git merge-tree` plus `git commit-tree` on the host
> - Execution hosts are often containers with no git config and no
resolvable hostname, so `commit-tree` fails with "Author identity
unknown"
> - That one local command failure marks the whole run as failed, even
though the run's work succeeded
> - This pull request gives sync-created merge commits an explicit,
deterministic identity at the call site
> - The benefit is that workspace finalize no longer depends on ambient
host git configuration
## Linked Issues or Issue Description
No existing issue found. I searched issues and PRs for "Author identity
unknown", "unrelated histories", and "commit-tree identity".
**What happened?**
A run finished its work, but workspace finalize failed. The host-side
sync ran `git commit-tree -p -p -m
"Paperclip remote git sync merge "`. Git exited with `Author
identity unknown ... fatal: unable to auto-detect email address (got
'node@.(none)')`. The adapter recorded the whole run as
failed, and the host worktree kept the stale head. Any container
deployment without a global gitconfig reproduces this; I observed it on
a Paperclip Cloud stack.
**Expected behavior**
Commits that the sync machinery itself creates must not depend on
ambient host git configuration. The merge commit is machine-authored, so
it should carry a deterministic Paperclip identity.
**Steps to reproduce**
1. Run the Paperclip server in a container with no
`user.name`/`user.email` git config and a hostname git cannot turn into
an email.
2. Let a run's sandbox branch diverge from the host worktree, so both
sides advance.
3. Workspace finalize calls `integrateImportedGitHead`. The `git
commit-tree` step fails with "Author identity unknown" and the run
fails.
## What Changed
- `git-workspace-sync.ts`: new exported `GIT_SYNC_COMMIT_IDENTITY_ARGS`
(`-c user.name=Paperclip -c user.email=noreply@paperclip.ing`), applied
to the `commit-tree` call in `integrateImportedGitHead`.
- `ssh.ts`: the SSH-sync copy of `integrateImportedGitHead` applies the
same identity args to its `commit-tree` call.
- New regression test: builds divergent histories in a repo with no
configured identity and asserts the sync merge commit is created with
the deterministic identity, correct parents, and merged tree.
## Verification
- `pnpm vitest run
packages/adapter-utils/src/git-workspace-sync.test.ts` — 18/18 pass.
- `pnpm --filter @paperclipai/adapter-utils typecheck` — clean.
- Negative proof: with the source fix stashed, the new test fails on the
identity assertion.
- Full `pnpm vitest run packages/adapter-utils`: every file passes
except `local-process-sandbox.test.ts`, which fails identically on an
untouched `master` checkout on macOS (bubblewrap-dependent,
pre-existing, unrelated).
## Risks
Low risk. The change only adds `-c` identity flags to two
machine-generated commit invocations. `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
environment variables still take precedence over `-c` when an operator
sets them, so existing deployments that configure an identity keep their
behavior.
## Model Used
- Claude Fable 5 (`claude-fable-5`), extended thinking, via Claude Code
CLI (tool use for code exploration, test runs, and verification).
## 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 (no
doc surface describes this internal sync path)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.../src/git-workspace-sync.test.ts | 55 +++++++++++++++++++
.../adapter-utils/src/git-workspace-sync.ts | 18 ++++++
packages/adapter-utils/src/ssh.ts | 3 +-
3 files changed, 75 insertions(+), 1 deletion(-)
diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts
index 1a233e4c5ac..5bc79af33ad 100644
--- a/packages/adapter-utils/src/git-workspace-sync.test.ts
+++ b/packages/adapter-utils/src/git-workspace-sync.test.ts
@@ -11,6 +11,7 @@ import {
createRemoteGitExportRef,
deleteLocalGitRef,
fetchGitBundleIntoLocalRef,
+ integrateImportedGitHead,
isMissingGitPrerequisiteError,
readGitWorkspaceSnapshot,
runLocalGit,
@@ -422,6 +423,60 @@ describe("git workspace sync", () => {
await deleteLocalGitRef({ localDir: host, ref: importedRef });
}
});
+
+ it("creates the concurrent-history merge commit with a deterministic identity", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-merge-identity-"));
+ cleanupDirs.push(rootDir);
+ // No repo-local user.name/user.email on purpose: execution hosts are
+ // containers without git config, where commit-tree cannot auto-detect an
+ // identity. Setup commits pass their identity inline so only the merge
+ // commit under test depends on the sync-supplied identity.
+ const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
+ const repo = path.join(rootDir, "repo");
+ await mkdir(repo, { recursive: true });
+ await git(repo, ["init"]);
+ await git(repo, ["checkout", "-b", "main"]);
+ await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
+ await git(repo, ["add", "tracked.txt"]);
+ await git(repo, [...setupIdentity, "commit", "-m", "base"]);
+ const baseHead = await git(repo, ["rev-parse", "HEAD"]);
+
+ await writeFile(path.join(repo, "local.txt"), "local\n", "utf8");
+ await git(repo, ["add", "local.txt"]);
+ await git(repo, [...setupIdentity, "commit", "-m", "local advance"]);
+ const currentHead = await git(repo, ["rev-parse", "HEAD"]);
+
+ await git(repo, ["checkout", "-b", "imported", baseHead]);
+ await writeFile(path.join(repo, "imported.txt"), "imported\n", "utf8");
+ await git(repo, ["add", "imported.txt"]);
+ await git(repo, [...setupIdentity, "commit", "-m", "sandbox change"]);
+ const importedHead = await git(repo, ["rev-parse", "HEAD"]);
+ await git(repo, ["checkout", "main"]);
+
+ // Ambient identity env vars would override the `-c` flags and make the
+ // assertion machine-dependent, so clear them for the call under test.
+ const identityEnvKeys = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"];
+ const savedEnv = new Map(identityEnvKeys.map((key) => [key, process.env[key]]));
+ for (const key of identityEnvKeys) delete process.env[key];
+ try {
+ await integrateImportedGitHead({ localDir: repo, importedHead });
+ } finally {
+ for (const [key, value] of savedEnv) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ }
+
+ const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" ");
+ expect(parents.slice(1)).toEqual([currentHead, importedHead]);
+ expect(await git(repo, ["log", "-1", "--format=%an|%ae|%cn|%ce"]))
+ .toBe("Paperclip|noreply@paperclip.ing|Paperclip|noreply@paperclip.ing");
+ expect(await git(repo, ["log", "-1", "--format=%s"]))
+ .toBe(`Paperclip remote git sync merge ${importedHead.slice(0, 12)}`);
+ const mergedTree = await git(repo, ["ls-tree", "--name-only", "HEAD"]);
+ expect(mergedTree).toContain("local.txt");
+ expect(mergedTree).toContain("imported.txt");
+ });
});
describe("sanitizeGitRemoteUrl", () => {
diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts
index fd3525ecc05..eb2973b1163 100644
--- a/packages/adapter-utils/src/git-workspace-sync.ts
+++ b/packages/adapter-utils/src/git-workspace-sync.ts
@@ -42,6 +42,23 @@ export function setExpensiveWorkspaceGitExecutor(executor: ExpensiveWorkspaceGit
export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const;
+/**
+ * Identity flags for commits the sync machinery itself creates (the merge
+ * commits that reconcile concurrent histories). Execution hosts are often
+ * containers with no git config and no resolvable hostname, so git cannot
+ * auto-detect an identity there and `commit-tree` hard-fails with "Author
+ * identity unknown" — which fails the whole run at finalize. Passing the
+ * identity per invocation keeps every deployment working without host
+ * configuration; `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables
+ * still take precedence over `-c` when an operator sets them.
+ */
+export const GIT_SYNC_COMMIT_IDENTITY_ARGS = [
+ "-c",
+ "user.name=Paperclip",
+ "-c",
+ "user.email=noreply@paperclip.ing",
+] as const;
+
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
@@ -476,6 +493,7 @@ export async function integrateImportedGitHead(input: {
const mergeCommit = await runLocalGit(
input.localDir,
[
+ ...GIT_SYNC_COMMIT_IDENTITY_ARGS,
"commit-tree",
mergedTreeId,
"-p",
diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts
index de86cdb678a..5cda8c60eb0 100644
--- a/packages/adapter-utils/src/ssh.ts
+++ b/packages/adapter-utils/src/ssh.ts
@@ -6,7 +6,7 @@ import os from "node:os";
import path from "node:path";
import { Transform } from "node:stream";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
-import { readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js";
+import { GIT_SYNC_COMMIT_IDENTITY_ARGS, readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js";
import type { RunProcessResult } from "./server-utils.js";
import type { DirectorySnapshot } from "./workspace-restore-merge.js";
import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
@@ -974,6 +974,7 @@ async function integrateImportedGitHead(input: {
const mergeCommit = await runLocalGit(
input.localDir,
[
+ ...GIT_SYNC_COMMIT_IDENTITY_ARGS,
"commit-tree",
mergedTreeId,
"-p",
From 393da0f67c063ab42f1c69e987239ef31b9d9cbc Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 18 Aug 2026 11:14:39 -0700
Subject: [PATCH 03/39] fix(adapter-utils): graft unrelated imported histories
instead of failing the run (#11638)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - At run finalize, the host imports the sandbox git history and
reconciles it with the local worktree in `integrateImportedGitHead`
> - Transported workspaces are depth-1 shallow clones, so the boundary
commit reads as parentless inside the sandbox
> - A `git commit --amend` there rewrites the boundary commit into a
root commit, and the re-imported history no longer connects to the host
history
> - `git merge-tree` has no common base to merge against, so the sync
throws "Failed to merge concurrent remote git histories" and the run
fails with its work stranded in the sandbox
> - This pull request grafts the imported tree onto the current head as
a single commit instead of failing
> - The benefit is that a history rewrite inside the sandbox can no
longer lose a run's work
## Linked Issues or Issue Description
No existing issue found. I searched issues and PRs for "unrelated
histories", "Failed to merge concurrent", and "shallow". Depends on
#11637 (merged; the graft commit reuses its identity constant). This PR
is now rebased onto `master`.
**What happened?**
An agent run amended a commit inside its sandbox workspace to address
review feedback. The sandbox clone is depth-1 shallow, so git treated
the boundary commit as parentless and the amend produced a root commit.
At finalize, the host-side sync failed with `Failed to merge concurrent
remote git histories for ` and the run was marked failed. A
follow-up run had to repair the branch by hand: fetch the true parent
from origin and rebuild the commit with `git commit-tree`.
**Expected behavior**
The sync must never strand completed work. When the imported history
shares no ancestor with the local one, the imported tree should still
land on the current head, with the imported message preserved and the
graft recorded.
**Steps to reproduce**
1. Start a run whose workspace transport uses the shallow clone path
(`withShallowGitWorkspaceClone`, depth 1).
2. Inside the sandbox workspace, run `git commit --amend` on the
boundary commit. The result is a parentless root commit.
3. Finish the run. The host-side `integrateImportedGitHead` finds no
merge base, `merge-tree` fails, and the run fails.
## What Changed
- `git-workspace-sync.ts`: new exported
`createUnrelatedHistoryGraftCommit` helper. It reads the imported head's
tree and message, and creates one commit on top of the current head with
the deterministic sync identity and a trailer that records the graft and
both shas.
- `integrateImportedGitHead` (both the remote-git-sync version and the
SSH copy in `ssh.ts`): when `merge-base` reports no common ancestor,
graft instead of throwing. The ref update keeps the same
compare-and-swap and concurrent-retry semantics as the merge path.
- The graft is gated on `git merge-base` exiting with status 1 — the
no-ancestor signal. Operational failures (timeout, missing object,
repository error) keep the loud merge failure instead of rewriting the
tip.
- New regression tests: one builds the exact shallow-amend shape (a root
commit rebuilt from the base tree) and asserts the graft lands on the
current head with the imported tree, subject, and graft trailer; one
integrates a well-formed sha the repository does not hold and asserts
the integration still throws with the branch tip unchanged.
## Verification
- `pnpm vitest run
packages/adapter-utils/src/git-workspace-sync.test.ts` — 19/19 pass
(includes the new graft test and the merge-base failure-discrimination
test).
- `pnpm --filter @paperclipai/adapter-utils typecheck` — clean.
- Full `pnpm vitest run packages/adapter-utils`: every file passes
except `local-process-sandbox.test.ts`, which fails identically on an
untouched `master` checkout on macOS (bubblewrap-dependent,
pre-existing, unrelated).
## Risks
- Behavioral shift: unrelated imported histories previously failed the
integration; now they land as a squash-graft. In this degenerate case
there is no base to merge against, so the imported tree is taken
wholesale and concurrent local-only tree changes are superseded at the
tip. The local commits keep their place in the graft's ancestry, and the
trailer records both shas, so nothing is unrecoverable. The old behavior
lost the imported work instead, which is the worse failure for an
autonomous run.
- The graft reuses the imported head's commit message, so branch history
still reads naturally after a sandbox rewrite.
## Model Used
- Claude Fable 5 (`claude-fable-5`), extended thinking, via Claude Code
CLI (tool use for code exploration, test runs, and verification).
## 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 (no
doc surface describes this internal sync path)
- [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
---
.../src/git-workspace-sync.test.ts | 57 ++++++++++++++
.../adapter-utils/src/git-workspace-sync.ts | 77 ++++++++++++++++++-
packages/adapter-utils/src/ssh.ts | 38 ++++++++-
3 files changed, 169 insertions(+), 3 deletions(-)
diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts
index 5bc79af33ad..e7f1a6b9a50 100644
--- a/packages/adapter-utils/src/git-workspace-sync.test.ts
+++ b/packages/adapter-utils/src/git-workspace-sync.test.ts
@@ -477,6 +477,63 @@ describe("git workspace sync", () => {
expect(mergedTree).toContain("local.txt");
expect(mergedTree).toContain("imported.txt");
});
+
+ it("grafts an imported head onto the current head when histories share no ancestor", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-graft-"));
+ cleanupDirs.push(rootDir);
+ const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
+ const repo = path.join(rootDir, "repo");
+ await mkdir(repo, { recursive: true });
+ await git(repo, ["init"]);
+ await git(repo, ["checkout", "-b", "main"]);
+ await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
+ await git(repo, ["add", "tracked.txt"]);
+ await git(repo, [...setupIdentity, "commit", "-m", "base"]);
+ const baseHead = await git(repo, ["rev-parse", "HEAD"]);
+
+ await writeFile(path.join(repo, "local.txt"), "local\n", "utf8");
+ await git(repo, ["add", "local.txt"]);
+ await git(repo, [...setupIdentity, "commit", "-m", "local advance"]);
+ const currentHead = await git(repo, ["rev-parse", "HEAD"]);
+
+ // The shape a depth-1 shallow clone produces after `git commit --amend`:
+ // a parentless root commit that shares no ancestor with the host history.
+ const importedTree = await git(repo, ["rev-parse", `${baseHead}^{tree}`]);
+ const importedHead = await git(repo, [...setupIdentity, "commit-tree", importedTree, "-m", "sandbox rewrite"]);
+
+ await integrateImportedGitHead({ localDir: repo, importedHead });
+
+ const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" ");
+ expect(parents.slice(1)).toEqual([currentHead]);
+ // The imported tree is taken wholesale: no base exists to merge against.
+ expect(await git(repo, ["rev-parse", "HEAD^{tree}"])).toBe(importedTree);
+ expect(await git(repo, ["log", "-1", "--format=%s"])).toBe("sandbox rewrite");
+ const body = await git(repo, ["log", "-1", "--format=%B"]);
+ expect(body).toContain(`Paperclip remote git sync graft ${importedHead.slice(0, 12)}`);
+ expect(body).toContain("shares no ancestor");
+ });
+
+ it("does not graft when merge-base fails for a reason other than missing ancestry", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-graft-"));
+ cleanupDirs.push(rootDir);
+ const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
+ const repo = path.join(rootDir, "repo");
+ await mkdir(repo, { recursive: true });
+ await git(repo, ["init"]);
+ await git(repo, ["checkout", "-b", "main"]);
+ await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
+ await git(repo, ["add", "tracked.txt"]);
+ await git(repo, [...setupIdentity, "commit", "-m", "base"]);
+ const currentHead = await git(repo, ["rev-parse", "HEAD"]);
+
+ // A well-formed sha the repository does not hold: merge-base fails with an
+ // object error (exit 128), not the no-ancestor signal (exit 1). The graft
+ // must not fire, and the integration keeps its loud failure.
+ const missingHead = "0123456789abcdef0123456789abcdef01234567";
+ await expect(integrateImportedGitHead({ localDir: repo, importedHead: missingHead }))
+ .rejects.toThrow(/Failed to merge concurrent remote git histories/);
+ expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead);
+ });
});
describe("sanitizeGitRemoteUrl", () => {
diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts
index eb2973b1163..6cae5e47247 100644
--- a/packages/adapter-utils/src/git-workspace-sync.ts
+++ b/packages/adapter-utils/src/git-workspace-sync.ts
@@ -433,6 +433,51 @@ export function buildRemoteGitDeltaBundleScript(input: {
].filter(Boolean).join("\n");
}
+/**
+ * Preserve imported work whose history does not connect to the local one.
+ *
+ * The dominant real-world cause is a history rewrite inside a transported
+ * workspace: transported clones are depth-1 shallow, so the boundary commit
+ * reads as parentless there and `git commit --amend` rewrites it into a root
+ * commit that shares no ancestor with the host history. A tree merge is
+ * impossible without a common ancestor, and failing the integration would
+ * discard the run's work. Instead, squash-graft the imported tree onto the
+ * current head as a single commit that reuses the imported head's message,
+ * with a trailer recording the graft. Concurrent local-only commits keep
+ * their place in history as the graft's ancestry; the imported tree is taken
+ * wholesale because no base exists to merge against. The caller advances the
+ * branch ref to the returned commit.
+ */
+export async function createUnrelatedHistoryGraftCommit(input: {
+ localDir: string;
+ currentHead: string;
+ importedHead: string;
+ syncLabel: string;
+}): Promise {
+ const importedTree = (await runLocalGit(input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ })).stdout.trim();
+ const importedMessage = (await runLocalGit(input.localDir, ["log", "-1", "--format=%B", input.importedHead], {
+ timeout: 10_000,
+ maxBuffer: 256 * 1024,
+ })).stdout;
+ const message = [
+ importedMessage.trim(),
+ "",
+ `(${input.syncLabel} graft ${input.importedHead.slice(0, 12)}: imported history shares no ancestor with ${input.currentHead.slice(0, 12)})`,
+ ].join("\n");
+ const graftCommit = await runLocalGit(
+ input.localDir,
+ [...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message],
+ {
+ timeout: 60_000,
+ maxBuffer: 64 * 1024,
+ },
+ );
+ return graftCommit.stdout.trim();
+}
+
export async function integrateImportedGitHead(input: {
localDir: string;
importedHead: string;
@@ -450,10 +495,18 @@ export async function integrateImportedGitHead(input: {
if (!currentHead || currentHead === input.importedHead) return;
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
+ // `git merge-base` exits 1 when the commits share no ancestor — the only
+ // outcome that authorizes the graft fallback below. Every other failure
+ // (timeout, missing object, repository error) must keep failing the
+ // integration instead of silently rewriting the tip.
+ let noCommonAncestor = false;
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
- }).catch(() => null);
+ }).catch((error: unknown) => {
+ noCommonAncestor = (error as { code?: unknown } | null)?.code === 1;
+ return null;
+ });
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
if (mergeBaseHead === input.importedHead) {
@@ -473,6 +526,28 @@ export async function integrateImportedGitHead(input: {
}
}
+ if (noCommonAncestor) {
+ // No common ancestor — merging is impossible and failing here would
+ // discard the imported work. Graft it onto the current head instead;
+ // see createUnrelatedHistoryGraftCommit.
+ const graftCommit = await createUnrelatedHistoryGraftCommit({
+ localDir: input.localDir,
+ currentHead,
+ importedHead: input.importedHead,
+ syncLabel: "Paperclip remote git sync",
+ });
+ try {
+ await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ return;
+ } catch (error) {
+ if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
+ throw error;
+ }
+ }
+
let mergedTree;
try {
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts
index 5cda8c60eb0..301d85cf585 100644
--- a/packages/adapter-utils/src/ssh.ts
+++ b/packages/adapter-utils/src/ssh.ts
@@ -6,7 +6,11 @@ import os from "node:os";
import path from "node:path";
import { Transform } from "node:stream";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
-import { GIT_SYNC_COMMIT_IDENTITY_ARGS, readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js";
+import {
+ createUnrelatedHistoryGraftCommit,
+ GIT_SYNC_COMMIT_IDENTITY_ARGS,
+ readSanitizedOriginRemoteUrl,
+} from "./git-workspace-sync.js";
import type { RunProcessResult } from "./server-utils.js";
import type { DirectorySnapshot } from "./workspace-restore-merge.js";
import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
@@ -931,10 +935,18 @@ async function integrateImportedGitHead(input: {
if (!currentHead || currentHead === input.importedHead) return;
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
+ // `git merge-base` exits 1 when the commits share no ancestor — the only
+ // outcome that authorizes the graft fallback below. Every other failure
+ // (timeout, missing object, repository error) must keep failing the
+ // integration instead of silently rewriting the tip.
+ let noCommonAncestor = false;
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
- }).catch(() => null);
+ }).catch((error: unknown) => {
+ noCommonAncestor = (error as { code?: unknown } | null)?.code === 1;
+ return null;
+ });
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
if (mergeBaseHead === input.importedHead) {
@@ -954,6 +966,28 @@ async function integrateImportedGitHead(input: {
}
}
+ if (noCommonAncestor) {
+ // No common ancestor — merging is impossible and failing here would
+ // discard the imported work. Graft it onto the current head instead;
+ // see createUnrelatedHistoryGraftCommit.
+ const graftCommit = await createUnrelatedHistoryGraftCommit({
+ localDir: input.localDir,
+ currentHead,
+ importedHead: input.importedHead,
+ syncLabel: "Paperclip SSH sync",
+ });
+ try {
+ await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ return;
+ } catch (error) {
+ if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
+ throw error;
+ }
+ }
+
let mergedTree;
try {
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
From fe803bedf163d187a0885a3f3721e707f06b3b49 Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Tue, 18 Aug 2026 11:23:27 -0700
Subject: [PATCH 04/39] feat(server): add a configurable cooldown to the
terminal workspace reaper (#11642)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is an open source app that manages AI agents for work.
> - The server manages execution workspaces and their worktrees.
> - The terminal workspace reaper removes a workspace when its issue
tree reaches a terminal state.
> - Immediate removal prevents a person from reopening recently
completed work.
> - This pull request adds a configurable cooldown before the reaper
archives the workspace.
> - The cooldown keeps recent work available and keeps immediate cleanup
available with value `0`.
## Linked Issues or Issue Description
Refs: #7790
**Problem**
The reaper archives an execution workspace and deletes its worktree as
soon as the issue tree becomes terminal. A person cannot reopen recent
work without extra effort.
**Expected behavior**
The reaper should keep a recently completed workspace during a
configurable cooldown window. It should archive older work and support
immediate cleanup when the value is `0`.
**Proposed solution**
Read the cooldown from `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS`. Use a
seven-day default. Use the latest terminal timestamp in the source issue
tree as the cooldown anchor.
## What Changed
- Add `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS` with a seven-day
default.
- Treat `0` as no cooldown and use the default for negative or
non-numeric values.
- Use the latest `completedAt` or `cancelledAt` value in the source
issue tree.
- Use `updatedAt` when a terminal timestamp is null.
- Skip candidates inside the cooldown and report them in
`skippedCooldown`.
- Recheck the cutoff during the guarded archive operation.
- Document the environment variable and add focused tests.
## Verification
- Run `npx vitest run
server/src/__tests__/execution-workspaces-service.test.ts`.
- Confirm that the test run passes 66 tests.
- Confirm that the tests cover a recent tree, an old tree, value `0`,
and a null terminal timestamp.
- Confirm that the changed files pass `tsc --noEmit`.
## Risks
The default changes terminal workspace cleanup from immediate removal to
a seven-day delay. A value of `0` preserves immediate cleanup. The
guarded archive check limits race risk during concurrent lifecycle
changes.
> 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.
## Model Used
OpenAI Codex, GPT-5, tool use and code execution. This model assisted
with the implementation review and PR preparation.
## 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
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---
doc/DEVELOPING.md | 6 +
.../execution-workspaces-service.test.ts | 109 ++++++++++++++++++
.../workspace-reaper-cooldown-config.test.ts | 54 +++++++++
server/src/config.ts | 17 +++
server/src/index.ts | 7 +-
server/src/services/execution-workspaces.ts | 92 +++++++++++++++
6 files changed, 283 insertions(+), 2 deletions(-)
create mode 100644 server/src/__tests__/workspace-reaper-cooldown-config.test.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index dd287b1ee5b..b2bf7cc7d4e 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -834,6 +834,12 @@ Environment overrides:
stale-backup warning threshold
- `PAPERCLIP_DB_BACKUP_ALERT_FILE=/path/to/failure-marker` lets external cron
wrappers surface the last failed backup in `/api/health`
+- `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS=` sets how long the
+ terminal-workspace reaper waits after an issue tree becomes terminal before it
+ archives the execution workspace and deletes the worktree. A person can reopen
+ the work inside this window. The default is `7`. A value of `0` disables the
+ cooldown and restores immediate reaping. A negative or non-numeric value falls
+ back to the default.
Without `PAPERCLIP_DB_BACKUP_ALERT_FILE`, health checks look for
`db-backup-to-s3.failure` in the backup directory, beside the backup directory,
diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts
index 6a5ee601609..9f3693e1b92 100644
--- a/server/src/__tests__/execution-workspaces-service.test.ts
+++ b/server/src/__tests__/execution-workspaces-service.test.ts
@@ -259,6 +259,10 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
pullRequestDetailsByKey.get(`${companyId}:${reference.number}`)
?? { state: "unknown", headRef: null, headSha: null }
),
+ // Disable the reaper cooldown for the delivery, terminal, race, and
+ // cleanup tests. They assert immediate reaping. The cooldown gets its own
+ // tests further down.
+ workspaceReaperCooldownDays: 0,
});
}, 20_000);
@@ -687,6 +691,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
// workspace first.
const service = executionWorkspaceService(db, {
resolvePullRequestDetails: async () => ({ state: "unknown", headRef: null, headSha: null }),
+ workspaceReaperCooldownDays: 0,
});
const firstSweep = await service.sweepTerminalWorkspaces(1);
@@ -727,6 +732,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
const service = executionWorkspaceService(db, {
resolvePullRequestDetails: async () => ({ state: "unknown", headRef: null, headSha: null }),
now: () => new Date(clockMs),
+ workspaceReaperCooldownDays: 0,
});
// An eligible ancestry workspace with an old updatedAt. Its source issue
@@ -829,6 +835,105 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
expect(finalState?.status).toBe("archived");
}, 30_000);
+ describe("reaper cooldown", () => {
+ const DAY_MS = 24 * 60 * 60 * 1000;
+ const nowMs = Date.UTC(2026, 5, 1);
+
+ function cooldownService(cooldownDays: number) {
+ return executionWorkspaceService(db, {
+ resolvePullRequestDetails: async (companyId, reference) =>
+ pullRequestDetailsByKey.get(`${companyId}:${reference.number}`)
+ ?? { state: "unknown", headRef: null, headSha: null },
+ now: () => new Date(nowMs),
+ workspaceReaperCooldownDays: cooldownDays,
+ });
+ }
+
+ async function statusOf(executionWorkspaceId: string) {
+ const [row] = await db
+ .select({ status: executionWorkspaces.status })
+ .from(executionWorkspaces)
+ .where(eq(executionWorkspaces.id, executionWorkspaceId));
+ return row?.status ?? null;
+ }
+
+ it("skips a terminal tree that is younger than the cooldown", async () => {
+ const seeded = await seedTerminalWorkspace({ mergedPr: true });
+ // Keep the workspace inside the sweep boundary that the fixed clock sets.
+ await db
+ .update(executionWorkspaces)
+ .set({ updatedAt: new Date(nowMs - DAY_MS) })
+ .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
+ // The issue became terminal one day ago. A cooldown of seven days is not
+ // over yet.
+ await db
+ .update(issues)
+ .set({ completedAt: new Date(nowMs - DAY_MS) })
+ .where(eq(issues.id, seeded.sourceIssueId));
+
+ const sweep = await cooldownService(7).sweepTerminalWorkspaces();
+
+ expect(sweep).toMatchObject({ archived: 0, skippedCooldown: 1 });
+ expect(await statusOf(seeded.executionWorkspaceId)).toBe("active");
+ }, 20_000);
+
+ it("archives a terminal tree that is older than the cooldown", async () => {
+ const seeded = await seedTerminalWorkspace({ mergedPr: true });
+ await db
+ .update(executionWorkspaces)
+ .set({ updatedAt: new Date(nowMs - DAY_MS) })
+ .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
+ // The issue became terminal ten days ago. The seven-day cooldown is over.
+ await db
+ .update(issues)
+ .set({ completedAt: new Date(nowMs - 10 * DAY_MS) })
+ .where(eq(issues.id, seeded.sourceIssueId));
+
+ const sweep = await cooldownService(7).sweepTerminalWorkspaces();
+
+ expect(sweep).toMatchObject({ archived: 1, skippedCooldown: 0 });
+ expect(await statusOf(seeded.executionWorkspaceId)).toBe("archived");
+ }, 20_000);
+
+ it("archives immediately when the cooldown is zero", async () => {
+ const seeded = await seedTerminalWorkspace({ mergedPr: true });
+ await db
+ .update(executionWorkspaces)
+ .set({ updatedAt: new Date(nowMs - DAY_MS) })
+ .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
+ // The issue became terminal now. A cooldown of zero disables the wait.
+ await db
+ .update(issues)
+ .set({ completedAt: new Date(nowMs) })
+ .where(eq(issues.id, seeded.sourceIssueId));
+
+ const sweep = await cooldownService(0).sweepTerminalWorkspaces();
+
+ expect(sweep).toMatchObject({ archived: 1, skippedCooldown: 0 });
+ expect(await statusOf(seeded.executionWorkspaceId)).toBe("archived");
+ }, 20_000);
+
+ it("falls back to updatedAt when completedAt is null and gates the archive", async () => {
+ const seeded = await seedTerminalWorkspace({ mergedPr: true });
+ // The done issue has no completedAt. The anchor falls back to updatedAt.
+ // Set updatedAt two days ago, inside the seven-day cooldown, so the sweep
+ // must skip.
+ await db
+ .update(executionWorkspaces)
+ .set({ updatedAt: new Date(nowMs - 2 * DAY_MS) })
+ .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
+ await db
+ .update(issues)
+ .set({ completedAt: null, updatedAt: new Date(nowMs - 2 * DAY_MS) })
+ .where(eq(issues.id, seeded.sourceIssueId));
+
+ const sweep = await cooldownService(7).sweepTerminalWorkspaces();
+
+ expect(sweep).toMatchObject({ archived: 0, skippedCooldown: 1 });
+ expect(await statusOf(seeded.executionWorkspaceId)).toBe("active");
+ }, 20_000);
+ });
+
it("does not treat an unrelated inbound issue mention as delivery evidence", async () => {
const seeded = await seedTerminalWorkspace();
const unrelatedIssueId = randomUUID();
@@ -965,6 +1070,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
const racingService = executionWorkspaceService(db, {
resolvePullRequestDetails: async (_companyId, reference) =>
pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" },
+ workspaceReaperCooldownDays: 0,
beforeTerminalWorkspaceCleanup: async () => {
await fs.writeFile(path.join(seeded.worktreePath, "late-work.txt"), "not delivered\n", "utf8");
},
@@ -994,6 +1100,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
const racingService = executionWorkspaceService(db, {
resolvePullRequestDetails: async (_companyId, reference) =>
pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" },
+ workspaceReaperCooldownDays: 0,
beforeTerminalWorkspaceCleanup: async (workspace) => {
// Stand in for a reopen and a fresh archive that ran after this sweep
// captured the generation. Raise the generation past the captured value,
@@ -1597,6 +1704,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
const racingService = executionWorkspaceService(db, {
resolvePullRequestDetails: async (_companyId, reference) =>
pullRequestDetailsByKey.get(`${failSeed.companyId}:${reference.number}`) ?? { state: "unknown" },
+ workspaceReaperCooldownDays: 0,
beforeTerminalWorkspaceCleanup: async (workspace) => {
await db
.update(executionWorkspaces)
@@ -1639,6 +1747,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
const lockingService = executionWorkspaceService(db, {
resolvePullRequestDetails: async (_companyId, reference) =>
pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" },
+ workspaceReaperCooldownDays: 0,
beforeTerminalWorkspaceCleanup: async () => {
try {
await runGit(seeded.worktreePath, ["commit", "--allow-empty", "-m", "Late commit"]);
diff --git a/server/src/__tests__/workspace-reaper-cooldown-config.test.ts b/server/src/__tests__/workspace-reaper-cooldown-config.test.ts
new file mode 100644
index 00000000000..2bcde2f4d69
--- /dev/null
+++ b/server/src/__tests__/workspace-reaper-cooldown-config.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { loadConfig } from "../config.ts";
+
+// The terminal-workspace reaper reads PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS.
+// These tests lock the parser contract: the default is 7 days, an explicit 0
+// means immediate reaping, and empty, whitespace-only, negative, or non-numeric
+// values fall back to the default. An empty or whitespace-only value must not
+// become 0, because that would delete terminal workspaces immediately.
+
+describe("workspace reaper cooldown config parsing", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("uses the 7 day default when the variable is not set", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", undefined);
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(7);
+ });
+
+ it("uses the 7 day default for an empty value", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(7);
+ });
+
+ it("uses the 7 day default for a whitespace-only value", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", " ");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(7);
+ });
+
+ it("keeps an explicit 0 as immediate reaping", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "0");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(0);
+ });
+
+ it("reads a positive whole number", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "14");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(14);
+ });
+
+ it("trims surrounding whitespace before it reads the number", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", " 3 ");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(3);
+ });
+
+ it("uses the 7 day default for a negative value", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "-1");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(7);
+ });
+
+ it("uses the 7 day default for a non-numeric value", () => {
+ vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "soon");
+ expect(loadConfig().workspaceReaperCooldownDays).toBe(7);
+ });
+});
diff --git a/server/src/config.ts b/server/src/config.ts
index 90d6cbf6b14..332b9d9c898 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -69,6 +69,7 @@ export interface Config {
databaseBackupIntervalMinutes: number;
databaseBackupRetentionDays: number;
databaseBackupDir: string;
+ workspaceReaperCooldownDays: number;
serveUi: boolean;
uiDevMiddleware: boolean;
secretsProvider: SecretProvider;
@@ -265,6 +266,21 @@ export function loadConfig(): Config {
fileDatabaseBackup?.dir ??
resolveDefaultBackupDir(),
);
+ // The terminal-workspace reaper waits this many days after an issue tree
+ // becomes terminal before it archives the workspace. A person can reopen the
+ // work inside this window. A value of 0 disables the cooldown and restores
+ // immediate reaping. A negative or non-numeric value falls back to the
+ // default. The day granularity and the default of 7 obey the
+ // PAPERCLIP_DB_BACKUP_RETENTION_DAYS precedent above.
+ const workspaceReaperCooldownDaysEnv =
+ process.env.PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS?.trim();
+ const workspaceReaperCooldownDaysRaw = Number(workspaceReaperCooldownDaysEnv);
+ const workspaceReaperCooldownDays =
+ workspaceReaperCooldownDaysEnv
+ && Number.isFinite(workspaceReaperCooldownDaysRaw)
+ && workspaceReaperCooldownDaysRaw >= 0
+ ? workspaceReaperCooldownDaysRaw
+ : 7;
const bindValidationErrors = validateConfiguredBindMode({
deploymentMode,
deploymentExposure,
@@ -307,6 +323,7 @@ export function loadConfig(): Config {
databaseBackupIntervalMinutes,
databaseBackupRetentionDays,
databaseBackupDir,
+ workspaceReaperCooldownDays,
serveUi:
process.env.SERVE_UI !== undefined
? process.env.SERVE_UI === "true"
diff --git a/server/src/index.ts b/server/src/index.ts
index c399f1b8b80..ffbf98ec3de 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1049,7 +1049,9 @@ export async function startServer(): Promise {
const mergedPullRequestConfirmations = issueThreadInteractionService(db as any, {
wakeup: heartbeat.wakeup,
});
- const terminalWorkspaces = executionWorkspaceService(db as any);
+ const terminalWorkspaces = executionWorkspaceService(db as any, {
+ workspaceReaperCooldownDays: config.workspaceReaperCooldownDays,
+ });
const scheduleMergedPullRequestConfirmationSweep = () => {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(mergedPullRequestConfirmations
@@ -1081,7 +1083,8 @@ export async function startServer(): Promise {
result.skippedActiveRun
+ result.skippedNonTerminalTree
+ result.skippedUndelivered
- + result.skippedRace;
+ + result.skippedRace
+ + result.skippedCooldown;
const nowMs = Date.now();
if (skipped > 0 && nowMs - lastTerminalWorkspaceSkipLogAt >= terminalWorkspaceSkipLogIntervalMs) {
lastTerminalWorkspaceSkipLogAt = nowMs;
diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts
index 7208bf0827c..360a5042f11 100644
--- a/server/src/services/execution-workspaces.ts
+++ b/server/src/services/execution-workspaces.ts
@@ -67,6 +67,24 @@ type RuntimeServiceReadDb = Pick;
type DbTransaction = Parameters[0]>[0];
const execFileAsync = promisify(execFile);
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
+
+// Return the timestamp when an issue became terminal. A `done` issue uses
+// `completedAt`. A `cancelled` issue uses `cancelledAt`. The reaper cooldown
+// measures the age of the terminal transition from this timestamp. Fall back to
+// `updatedAt` when the terminal timestamp is null, so an old issue that lacks a
+// recorded transition time still gates the cooldown. Return null for a
+// non-terminal issue.
+function issueTerminalTimestamp(issue: {
+ status: string;
+ completedAt: Date | null;
+ cancelledAt: Date | null;
+ updatedAt: Date;
+}): Date | null {
+ if (issue.status === "done") return issue.completedAt ?? issue.updatedAt;
+ if (issue.status === "cancelled") return issue.cancelledAt ?? issue.updatedAt;
+ return null;
+}
+
const WORKSPACE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence";
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
export const ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON = "issue_terminal";
@@ -208,6 +226,10 @@ export type ExecutionWorkspaceServiceOptions = {
resolvePullRequestDetails?: PullRequestMergeDetailsResolver;
now?: () => Date;
beforeTerminalWorkspaceCleanup?: (workspace: ExecutionWorkspaceRow) => Promise;
+ // The terminal-workspace reaper waits this many days after an issue tree
+ // becomes terminal before it archives the workspace. A value of 0 disables
+ // the cooldown. The default is 7 days.
+ workspaceReaperCooldownDays?: number;
};
function parseGitHubRepository(repoUrl: string | null) {
@@ -1230,6 +1252,14 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
const recoveryActionsSvc = issueRecoveryActionService(db);
const resolvePullRequestDetails = opts.resolvePullRequestDetails ?? createPullRequestMergeDetailsResolver(db);
const now = opts.now ?? (() => new Date());
+ // The reaper waits this long after an issue tree becomes terminal before it
+ // archives the workspace. A value of 0 disables the cooldown, so the reaper
+ // archives a terminal workspace on the same sweep. A negative value also
+ // disables the cooldown.
+ const workspaceReaperCooldownMs = Math.max(
+ 0,
+ (opts.workspaceReaperCooldownDays ?? 7) * 24 * 60 * 60 * 1000,
+ );
const pullRequestStateCache = new Map<
string,
{
@@ -1268,6 +1298,9 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
.select({
id: issues.id,
status: issues.status,
+ completedAt: issues.completedAt,
+ cancelledAt: issues.cancelledAt,
+ updatedAt: issues.updatedAt,
})
.from(issues)
.where(and(
@@ -1323,6 +1356,17 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
const sourceIssue = issueTree.find((issue) => issue.id === workspace.sourceIssueId) ?? null;
const sourceIssueTerminal = Boolean(sourceIssue && TERMINAL_ISSUE_STATUSES.has(sourceIssue.status));
const subtreeTerminal = Boolean(sourceIssue && issueTree.every((issue) => TERMINAL_ISSUE_STATUSES.has(issue.status)));
+ // The cooldown anchor is the most recent terminal timestamp across the whole
+ // issue tree. The reaper compares it against the cooldown window. A null
+ // anchor means no issue in the tree is terminal yet, so the cooldown never
+ // applies (the terminal-tree gates above already block the archive).
+ let cooldownAnchor: Date | null = null;
+ for (const issue of issueTree) {
+ const terminalAt = issueTerminalTimestamp(issue);
+ if (terminalAt && (!cooldownAnchor || terminalAt.getTime() > cooldownAnchor.getTime())) {
+ cooldownAnchor = terminalAt;
+ }
+ }
let mergedPullRequest = false;
let pullRequestStateUnknown = false;
const workspaceHeadSha = git?.repoRoot && git.workspacePath
@@ -1386,6 +1430,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
}),
sourceIssueTerminal,
subtreeTerminal,
+ cooldownAnchor,
workspaceDirty: Boolean(git?.hasDirtyTrackedFiles || git?.hasUntrackedFiles),
workspaceHeadSha,
};
@@ -2460,6 +2505,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
skippedUndelivered: 0,
skippedRace: 0,
skippedReopened: 0,
+ skippedCooldown: 0,
clearedStaleReopenPending: 0,
};
}
@@ -2523,6 +2569,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
skippedUndelivered: 0,
skippedRace: 0,
skippedReopened: 0,
+ skippedCooldown: 0,
clearedStaleReopenPending: 0,
};
@@ -2564,6 +2611,23 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
result.skippedUndelivered += 1;
continue;
}
+ // Hold the archive during the cooldown window. The anchor is the most
+ // recent terminal timestamp across the issue tree. A person can reopen
+ // the work inside this window. A cooldown of 0 disables the check, so the
+ // reaper archives the workspace on the same sweep. The archive statement
+ // below re-checks the same cutoff under the lifecycle lock, so the loop
+ // check and the guarded statement agree.
+ const cooldownCutoff = workspaceReaperCooldownMs > 0
+ ? new Date(now().getTime() - workspaceReaperCooldownMs)
+ : null;
+ if (
+ cooldownCutoff
+ && assessment.cooldownAnchor
+ && assessment.cooldownAnchor.getTime() > cooldownCutoff.getTime()
+ ) {
+ result.skippedCooldown += 1;
+ continue;
+ }
if (reopenPending) {
const pendingSince = readMetadataReopenPendingConsumptionSince(
workspace.metadata as Record | null,
@@ -2689,6 +2753,34 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
)
SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled')
)`,
+ // Re-check the cooldown under the lifecycle lock. This predicate
+ // matches the loop check above: block the archive when any issue in
+ // the tree became terminal after the cutoff. The tree walk mirrors
+ // the terminal-tree walk above. A null cutoff means the cooldown is
+ // disabled, so this predicate drops out of the guard.
+ cooldownCutoff
+ ? sql`NOT EXISTS (
+ WITH RECURSIVE cooldown_tree(id, status, completed_at, cancelled_at, updated_at) AS (
+ SELECT root.id, root.status, root.completed_at, root.cancelled_at, root.updated_at
+ FROM ${issues} root
+ WHERE root.company_id = ${workspace.companyId}
+ AND root.id = ${workspace.sourceIssueId}
+ UNION ALL
+ SELECT child.id, child.status, child.completed_at, child.cancelled_at, child.updated_at
+ FROM ${issues} child
+ JOIN cooldown_tree parent ON child.parent_id = parent.id
+ WHERE child.company_id = ${workspace.companyId}
+ )
+ SELECT 1 FROM cooldown_tree
+ WHERE COALESCE(
+ CASE
+ WHEN status = 'done' THEN completed_at
+ WHEN status = 'cancelled' THEN cancelled_at
+ END,
+ updated_at
+ ) > ${cooldownCutoff.toISOString()}::timestamptz
+ )`
+ : undefined,
))
.returning()
.then((rows) => rows[0] ?? null);
From 120ae5428fa29bee300bcf806491cd4d965fbb7c Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Tue, 18 Aug 2026 11:43:39 -0700
Subject: [PATCH 05/39] feat(server): add a one-click relink action for
detached custom-image templates (#11641)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip environments can use captured custom images for agent runs
> - A configuration fingerprint change can detach a valid custom-image
template
> - Operators need a safe way to confirm that the image still matches
the boot source
> - This pull request adds a guarded relink action with drift
classification and audit logging
> - The benefit is a deliberate relink without a new sandbox boot or
provider snapshot
## Linked Issues or Issue Description
**Subsystem affected**
Cross-cutting environment, server, and UI behavior.
**Problem or motivation**
A custom-image template detaches when the environment configuration
fingerprint changes. The runtime then uses the base image, even when the
boot source did not change. The only prior remedy required a full
re-capture.
**Proposed solution**
Add an operator-triggered relink action. Classify configuration drift
from a server-owned boot-relevant snapshot. Relink knob-only drift
without confirmation. Require explicit confirmation for boot-source or
unclassified drift. Guard the route for instance administrators and
record a safe activity event.
**Alternatives considered**
Keep requiring a full re-capture. This adds a sandbox boot and provider
snapshot for cases where the image remains correct.
**Roadmap alignment**
The roadmap has no matching custom-image relink item. This change
addresses an environment operation gap.
**Additional context**
The relink response exposes raw drift values only in the transient 409
response to the instance administrator. The service never persists or
logs fingerprints or configuration values. Reserved identity-path
segments fail closed.
## What Changed
- Add `relinkActiveTemplate` with drift classification and conditional
fingerprint update.
- Persist a server-owned boot-relevant configuration snapshot during
capture.
- Add the guarded relink route with strict request validation and
activity logging.
- Add the relink action and confirmation flow to the environment page.
- Add service, route, UI, and OpenAPI coverage.
## Verification
- Run the focused service suite: `pnpm vitest run
server/src/services/environment-custom-images-service.test.ts`.
- Run the focused route suite: `pnpm vitest run
server/src/routes/environment-custom-image-routes.test.ts`.
- Run the focused UI suite: `pnpm vitest run
ui/src/pages/CompanyEnvironments.test.tsx`.
- Run server and UI TypeScript checks.
- Confirm the OpenAPI snapshot matches the new route.
- Confirm all required GitHub checks pass on commit
`e46fdcfe94a719be854adf8849d30714e5b70b93`.
- Confirm Greptile reports 5/5 with no unresolved review threads.
## Risks
The relink action can keep an image after configuration drift. The
service requires explicit confirmation for boot-source or unclassified
drift. Reserved path segments produce a safe unresolved marker and never
enter stored values.
## Model Used
OpenAI GPT-5 Codex. The model used repository inspection, GitHub
operations, and PR preparation with tool use and code execution. The
runtime did not expose a context-window value or a separate
reasoning-mode value.
## 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
---
packages/shared/src/index.ts | 2 +
.../validators/environment-custom-images.ts | 6 +
packages/shared/src/validators/index.ts | 2 +
.../environment-custom-image-routes.test.ts | 80 +++
.../environment-custom-images-service.test.ts | 501 +++++++++++++++++-
server/src/routes/environments.ts | 26 +
server/src/routes/openapi.ts | 26 +
.../environment-custom-image-runtime.ts | 311 ++++++++++-
.../src/services/environment-custom-images.ts | 189 ++++++-
ui/src/api/environments.ts | 35 ++
ui/src/pages/CompanyEnvironments.test.tsx | 136 +++++
ui/src/pages/CompanyEnvironments.tsx | 94 +++-
12 files changed, 1393 insertions(+), 15 deletions(-)
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index fb079ee85d7..f4ac04960d8 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -2438,6 +2438,7 @@ export {
startEnvironmentCustomImageSetupSessionSchema,
finishEnvironmentCustomImageSetupSessionSchema,
cancelEnvironmentCustomImageSetupSessionSchema,
+ relinkEnvironmentCustomImageTemplateSchema,
createEnvironmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTerminalSessionTokenSchema,
type EnvironmentCustomImageSetupConnectionSummary,
@@ -2446,6 +2447,7 @@ export {
type StartEnvironmentCustomImageSetupSession,
type FinishEnvironmentCustomImageSetupSession,
type CancelEnvironmentCustomImageSetupSession,
+ type RelinkEnvironmentCustomImageTemplate,
type CreateEnvironmentCustomImageTerminalSessionToken,
type EnvironmentCustomImageTerminalSessionToken,
} from "./validators/environment-custom-images.js";
diff --git a/packages/shared/src/validators/environment-custom-images.ts b/packages/shared/src/validators/environment-custom-images.ts
index 07eb945f200..9ef97e3d690 100644
--- a/packages/shared/src/validators/environment-custom-images.ts
+++ b/packages/shared/src/validators/environment-custom-images.ts
@@ -94,6 +94,12 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
export type CancelEnvironmentCustomImageSetupSession =
z.infer;
+export const relinkEnvironmentCustomImageTemplateSchema = z.object({
+ confirmBootSourceDrift: z.boolean().optional().default(false),
+}).strict();
+export type RelinkEnvironmentCustomImageTemplate =
+ z.infer;
+
export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({});
export type CreateEnvironmentCustomImageTerminalSessionToken =
z.infer;
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 3b939c21089..2fc001d5a40 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -89,6 +89,7 @@ export {
startEnvironmentCustomImageSetupSessionSchema,
finishEnvironmentCustomImageSetupSessionSchema,
cancelEnvironmentCustomImageSetupSessionSchema,
+ relinkEnvironmentCustomImageTemplateSchema,
createEnvironmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTerminalSessionTokenSchema,
type EnvironmentCustomImageSetupConnectionSummary,
@@ -97,6 +98,7 @@ export {
type StartEnvironmentCustomImageSetupSession,
type FinishEnvironmentCustomImageSetupSession,
type CancelEnvironmentCustomImageSetupSession,
+ type RelinkEnvironmentCustomImageTemplate,
type CreateEnvironmentCustomImageTerminalSessionToken,
type EnvironmentCustomImageTerminalSessionToken,
} from "./environment-custom-images.js";
diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts
index 649055adebd..82b8eda9d37 100644
--- a/server/src/__tests__/environment-custom-image-routes.test.ts
+++ b/server/src/__tests__/environment-custom-image-routes.test.ts
@@ -2,6 +2,7 @@ import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { environmentRoutes } from "../routes/environments.js";
+import { HttpError } from "../errors.js";
import {
environmentCustomImageTerminalConnectionRegistry,
environmentCustomImageTerminalSessionStore,
@@ -40,6 +41,7 @@ const mockEnvironmentCustomImageService = vi.hoisted(() => ({
finishSetupSession: vi.fn(),
cancelSetupSession: vi.fn(),
rollbackTemplate: vi.fn(),
+ relinkActiveTemplate: vi.fn(),
disableTemplate: vi.fn(),
cleanupExpiredSetupSessions: vi.fn(),
}));
@@ -292,6 +294,10 @@ describe("environment customImage setup routes", () => {
templateRef: "disabled-template-secret",
status: "revoked",
}));
+ mockEnvironmentCustomImageService.relinkActiveTemplate.mockResolvedValue({
+ template: createTemplate({ id: "template-1", templateRef: "relinked-template-secret" }),
+ classification: "knob_only",
+ });
});
it("starts a setup session, returns the live payload, and logs redacted details", async () => {
@@ -641,4 +647,78 @@ describe("environment customImage setup routes", () => {
expect(activity).not.toContain("old-template-secret");
expect(activity).not.toContain("disabled-template-secret");
});
+
+ it("relinks the active template through the company-scoped route", async () => {
+ const res = await request(createApp(boardActor()))
+ .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
+ .send({ confirmBootSourceDrift: true });
+
+ expect(res.status).toBe(200);
+ expect(res.body.classification).toBe("knob_only");
+ expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledWith({
+ environmentId: "env-1",
+ confirmBootSourceDrift: true,
+ actor: {
+ actorType: "user",
+ actorId: "user-1",
+ agentId: null,
+ runId: null,
+ agentApiKeyId: null,
+ },
+ companyId: "company-1",
+ });
+ });
+
+ it("defaults the confirmation flag to false and rejects unknown body keys", async () => {
+ const app = createApp(boardActor());
+ const withoutFlag = await request(app)
+ .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
+ .send({});
+ expect(withoutFlag.status).toBe(200);
+ expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ confirmBootSourceDrift: false }),
+ );
+
+ // The strict schema rejects unknown keys before the handler runs, so the
+ // relink service is never reached for the malformed body.
+ const unknownKey = await request(app)
+ .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
+ .send({ confirmBootSourceDrift: false, unexpected: true });
+ expect(unknownKey.status).not.toBe(200);
+ expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledTimes(1);
+ });
+
+ it("propagates the drift conflict from the relink service", async () => {
+ mockEnvironmentCustomImageService.relinkActiveTemplate.mockImplementationOnce(async () => {
+ throw new HttpError(409, "Confirm the relink to keep the captured snapshot.", {
+ classification: "boot_source_drift",
+ driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }],
+ });
+ });
+
+ const res = await request(createApp(boardActor()))
+ .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
+ .send({});
+
+ expect(res.status).toBe(409);
+ });
+
+ it("denies agent API key actors before the relink service is called", async () => {
+ const res = await request(createApp(agentActor()))
+ .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
+ .send({});
+ expect(res.status).toBe(403);
+ expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled();
+ });
+
+ it("denies non-admin board users before the relink service is called", async () => {
+ const res = await request(createApp(boardActor({
+ companyIds: ["company-2"],
+ isInstanceAdmin: false,
+ })))
+ .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
+ .send({});
+ expect(res.status).toBe(403);
+ expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled();
+ });
});
diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts
index 51a8e4691ad..335c1e61424 100644
--- a/server/src/__tests__/environment-custom-images-service.test.ts
+++ b/server/src/__tests__/environment-custom-images-service.test.ts
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
-import { eq } from "drizzle-orm";
+import { desc, eq } from "drizzle-orm";
import {
+ activityLog,
companies,
createDb,
environmentCustomImageSetupSessions,
@@ -866,3 +867,501 @@ describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => {
expect(outOfSync.activeTemplateMatchesConfig).toBe(false);
});
});
+
+function secretPluginManifest() {
+ return {
+ id: "paperclip.fake-secret-sandbox-provider",
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: "Fake Secret Sandbox Provider",
+ categories: ["automation"],
+ capabilities: ["environment.drivers.register"],
+ entrypoints: { worker: "./dist/worker.js" },
+ environmentDrivers: [
+ {
+ driverKey: "fake-secret-plugin",
+ kind: "sandbox_provider",
+ displayName: "Fake Secret Sandbox Provider",
+ supportsInteractiveSetup: true,
+ interactiveSetupConnectionTypes: ["ssh"],
+ supportsTemplateCapture: true,
+ templateRefKind: "snapshot",
+ templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
+ // apiUrl overlaps a secret exactly; auth.token is a child of secret
+ // `auth`; credentials is a parent of secret `credentials.secret`.
+ templateIdentityPaths: ["apiUrl", "auth.token", "credentials"],
+ supportsTemplateDelete: true,
+ configSchema: {
+ type: "object",
+ properties: {
+ apiUrl: { type: "string", format: "secret-ref" },
+ auth: { type: "string", format: "secret-ref" },
+ credentials: {
+ type: "object",
+ properties: { secret: { type: "string", format: "secret-ref" } },
+ },
+ },
+ },
+ },
+ ],
+ } as const;
+}
+
+function invalidIdentityPluginManifest() {
+ return {
+ id: "paperclip.fake-invalid-sandbox-provider",
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: "Fake Invalid Identity Sandbox Provider",
+ categories: ["automation"],
+ capabilities: ["environment.drivers.register"],
+ entrypoints: { worker: "./dist/worker.js" },
+ environmentDrivers: [
+ {
+ driverKey: "fake-invalid-plugin",
+ kind: "sandbox_provider",
+ displayName: "Fake Invalid Identity Sandbox Provider",
+ supportsInteractiveSetup: true,
+ interactiveSetupConnectionTypes: ["ssh"],
+ supportsTemplateCapture: true,
+ templateRefKind: "snapshot",
+ templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
+ // A valid path plus two that must fail canonicalization.
+ templateIdentityPaths: ["apiUrl", "bad path!", "a..b"],
+ supportsTemplateDelete: true,
+ configSchema: { type: "object" },
+ },
+ ],
+ } as const;
+}
+
+function prototypeKeyIdentityPluginManifest() {
+ return {
+ id: "paperclip.fake-prototype-key-sandbox-provider",
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: "Fake Prototype Key Sandbox Provider",
+ categories: ["automation"],
+ capabilities: ["environment.drivers.register"],
+ entrypoints: { worker: "./dist/worker.js" },
+ environmentDrivers: [
+ {
+ driverKey: "fake-prototype-key-plugin",
+ kind: "sandbox_provider",
+ displayName: "Fake Prototype Key Sandbox Provider",
+ supportsInteractiveSetup: true,
+ interactiveSetupConnectionTypes: ["ssh"],
+ supportsTemplateCapture: true,
+ templateRefKind: "snapshot",
+ templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
+ // Every prototype key has an identifier shape but names no own field.
+ // Each must fail canonicalization and record only the marker.
+ templateIdentityPaths: ["__proto__", "constructor", "prototype", "nested.__proto__"],
+ supportsTemplateDelete: true,
+ configSchema: { type: "object" },
+ },
+ ],
+ } as const;
+}
+
+describeEmbeddedPostgres("environmentCustomImageService relink", () => {
+ let stopDb: (() => Promise) | null = null;
+ let db!: ReturnType;
+
+ beforeAll(async () => {
+ const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-relink");
+ stopDb = started.stop;
+ db = createDb(started.connectionString);
+ });
+
+ afterEach(async () => {
+ await db.delete(activityLog);
+ await db.delete(environmentCustomImageSetupSessions);
+ await db.delete(environmentCustomImageTemplates);
+ await db.delete(plugins);
+ await db.delete(environments);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await stopDb?.();
+ });
+
+ async function seed(opts?: {
+ manifest?: ReturnType | ReturnType | ReturnType | ReturnType;
+ config?: Record;
+ }) {
+ const manifest = opts?.manifest ?? pluginManifest();
+ const provider = manifest.environmentDrivers[0]!.driverKey;
+ const companyId = randomUUID();
+ const environmentId = randomUUID();
+ await db.insert(companies).values(
+ { id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` },
+ );
+ await db.insert(environments).values({
+ id: environmentId,
+ name: `Fake ${environmentId.slice(0, 8)}`,
+ driver: "sandbox",
+ status: "active",
+ config: opts?.config ?? { provider, image: "fake:base", reuseLease: false },
+ envVars: {},
+ });
+ await db.insert(plugins).values({
+ pluginKey: manifest.id,
+ packageName: `paperclip-plugin-${provider}`,
+ version: "0.1.0",
+ apiVersion: 1,
+ categories: ["automation"],
+ manifestJson: manifest,
+ status: "ready",
+ });
+ return { companyId, environmentId, provider };
+ }
+
+ const relinkActor = {
+ actorType: "user" as const,
+ actorId: "user-1",
+ agentId: null,
+ runId: null,
+ agentApiKeyId: null,
+ };
+
+ async function activeTemplateRow(environmentId: string) {
+ return db
+ .select()
+ .from(environmentCustomImageTemplates)
+ .where(eq(environmentCustomImageTemplates.environmentId, environmentId))
+ .orderBy(desc(environmentCustomImageTemplates.createdAt))
+ .then((rows) => rows[0]!);
+ }
+
+ async function relinkActivityRows(environmentId: string) {
+ return db
+ .select()
+ .from(activityLog)
+ .where(eq(activityLog.entityId, environmentId));
+ }
+
+ it("keeps secret-ref values out of metadata, the response, and activity, and relinks with the flag", async () => {
+ const config = {
+ provider: "fake-secret-plugin",
+ image: "fake:base",
+ apiUrl: "https://secret-endpoint.example",
+ auth: "auth-secret-value",
+ credentials: { secret: "cred-secret-value", region: "eu" },
+ reuseLease: false,
+ };
+ const { companyId, environmentId } = await seed({ manifest: secretPluginManifest(), config });
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+
+ const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
+ const promoted = await service.finishSetupSession({ sessionId: started.session.id });
+
+ // The public template response never carries the server-internal snapshot.
+ expect((promoted.template.metadata as Record).bootRelevantConfig).toBeUndefined();
+
+ // The persisted snapshot lives in the row. Every secret-ref overlap is
+ // excluded with no value (exact, child, parent).
+ const persistedRow = await activeTemplateRow(environmentId);
+ const boot = (persistedRow.metadata as Record).bootRelevantConfig as {
+ values: Record;
+ excludedPaths: string[];
+ };
+ expect(boot.excludedPaths).toEqual(expect.arrayContaining(["apiUrl", "auth.token", "credentials"]));
+ expect(Object.keys(boot.values)).not.toContain("apiUrl");
+ expect(Object.keys(boot.values)).not.toContain("auth.token");
+ expect(Object.keys(boot.values)).not.toContain("credentials");
+ // Neither the persisted snapshot nor the response leaks a secret value.
+ for (const metadataJson of [
+ JSON.stringify(persistedRow.metadata),
+ JSON.stringify(promoted.template.metadata),
+ ]) {
+ expect(metadataJson).not.toContain("secret-endpoint.example");
+ expect(metadataJson).not.toContain("auth-secret-value");
+ expect(metadataJson).not.toContain("cred-secret-value");
+ }
+
+ // An excluded path forces the fail-closed unclassified result: the flag is required.
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({ status: 409 });
+ const relinked = await service.relinkActiveTemplate({
+ environmentId,
+ confirmBootSourceDrift: true,
+ actor: relinkActor,
+ companyId,
+ });
+ expect(relinked.classification).toBe("unclassified");
+ const responseJson = JSON.stringify(relinked);
+ expect(responseJson).not.toContain("secret-endpoint.example");
+ expect(responseJson).not.toContain("auth-secret-value");
+ expect(responseJson).not.toContain("cred-secret-value");
+
+ const activities = await relinkActivityRows(environmentId);
+ const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked");
+ expect(relinkActivity).toBeDefined();
+ const activityJson = JSON.stringify(relinkActivity!.details);
+ expect(activityJson).not.toContain("secret-endpoint.example");
+ expect(activityJson).not.toContain("auth-secret-value");
+ expect(activityJson).not.toContain("cred-secret-value");
+ // Drift detail carries path names only, never a fingerprint value.
+ expect(activityJson).not.toContain(promoted.template.sourceEnvironmentConfigFingerprint);
+ });
+
+ it("fails closed for legacy templates without a boot-relevant snapshot", async () => {
+ const { companyId, environmentId } = await seed();
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ await db.insert(environmentCustomImageTemplates).values({
+ environmentId,
+ provider: "fake-plugin",
+ templateKind: "snapshot",
+ templateRef: "snapshot-legacy",
+ sourceEnvironmentConfigFingerprint: "stale-fingerprint",
+ status: "active",
+ metadata: { runtimeConfigBinding: { field: "customTemplate", unsetFields: ["image"] } },
+ });
+
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({ status: 409 });
+ const beforeRow = await activeTemplateRow(environmentId);
+ expect(beforeRow.sourceEnvironmentConfigFingerprint).toBe("stale-fingerprint");
+
+ const relinked = await service.relinkActiveTemplate({
+ environmentId,
+ confirmBootSourceDrift: true,
+ actor: relinkActor,
+ companyId,
+ });
+ expect(relinked.classification).toBe("unclassified");
+ const afterRow = await activeTemplateRow(environmentId);
+ expect(afterRow.sourceEnvironmentConfigFingerprint).not.toBe("stale-fingerprint");
+ });
+
+ it("relinks a knob-only change without a flag and keeps the fail-closed runtime gate", async () => {
+ const { companyId, environmentId } = await seed();
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
+ const promoted = await service.finishSetupSession({ sessionId: started.session.id });
+
+ // A non-boot field changes the fingerprint and detaches the template.
+ await db.update(environments)
+ .set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } })
+ .where(eq(environments.id, environmentId));
+ const detached = await service.getOverview({ environmentId });
+ expect(detached.activeTemplateMatchesConfig).toBe(false);
+
+ const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId });
+ expect(relinked.classification).toBe("knob_only");
+ const afterRelink = await service.getOverview({ environmentId });
+ expect(afterRelink.activeTemplateMatchesConfig).toBe(true);
+ const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
+ id: environmentId,
+ driver: "sandbox",
+ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" },
+ }, { heartbeatRunId: randomUUID() });
+ expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef });
+
+ // The gate stays fail-closed: a base image change detaches again.
+ await db.update(environments)
+ .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false, region: "eu" } })
+ .where(eq(environments.id, environmentId));
+ const afterImageChange = await service.getOverview({ environmentId });
+ expect(afterImageChange.activeTemplateMatchesConfig).toBe(false);
+ });
+
+ it("requires the flag for boot-source drift and re-stamps only after confirmation", async () => {
+ const { companyId, environmentId } = await seed();
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
+ await service.finishSetupSession({ sessionId: started.session.id });
+
+ await db.update(environments)
+ .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } })
+ .where(eq(environments.id, environmentId));
+
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({
+ status: 409,
+ details: {
+ classification: "boot_source_drift",
+ driftedPaths: expect.arrayContaining([
+ expect.objectContaining({ path: "image", from: "fake:base", to: "fake:other" }),
+ ]),
+ },
+ });
+
+ const relinked = await service.relinkActiveTemplate({
+ environmentId,
+ confirmBootSourceDrift: true,
+ actor: relinkActor,
+ companyId,
+ });
+ expect(relinked.classification).toBe("boot_source_drift");
+ const afterRelink = await service.getOverview({ environmentId });
+ expect(afterRelink.activeTemplateMatchesConfig).toBe(true);
+ });
+
+ it("re-stamps only the active template and never a superseded one", async () => {
+ const { companyId, environmentId } = await seed();
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ const first = await service.finishSetupSession({
+ sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id,
+ });
+ const second = await service.finishSetupSession({
+ sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id,
+ });
+ // The first template is now superseded; the second is active.
+ const supersededBefore = await db
+ .select()
+ .from(environmentCustomImageTemplates)
+ .where(eq(environmentCustomImageTemplates.id, first.template.id))
+ .then((rows) => rows[0]!);
+
+ await db.update(environments)
+ .set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } })
+ .where(eq(environments.id, environmentId));
+ const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId });
+ expect(relinked.template.id).toBe(second.template.id);
+
+ const supersededAfter = await db
+ .select()
+ .from(environmentCustomImageTemplates)
+ .where(eq(environmentCustomImageTemplates.id, first.template.id))
+ .then((rows) => rows[0]!);
+ expect(supersededAfter.status).toBe("superseded");
+ expect(supersededAfter.sourceEnvironmentConfigFingerprint)
+ .toBe(supersededBefore.sourceEnvironmentConfigFingerprint);
+ });
+
+ it("aborts with a conflict and writes no active template when none exists", async () => {
+ const { companyId, environmentId } = await seed();
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({ status: 404 });
+ const activities = await relinkActivityRows(environmentId);
+ expect(activities.filter((row) => row.action === "environment.custom_image_template.relinked")).toHaveLength(0);
+ });
+
+ it("classifies an invalid driver identity path as unclassified without treating it as value-bearing", async () => {
+ const { companyId, environmentId } = await seed({ manifest: invalidIdentityPluginManifest(), config: {
+ provider: "fake-invalid-plugin",
+ image: "fake:base",
+ reuseLease: false,
+ } });
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
+ const promoted = await service.finishSetupSession({ sessionId: started.session.id });
+
+ expect((promoted.template.metadata as Record).bootRelevantConfig).toBeUndefined();
+ const persistedRow = await activeTemplateRow(environmentId);
+ const boot = (persistedRow.metadata as Record).bootRelevantConfig as {
+ values: Record;
+ excludedPaths: string[];
+ };
+ expect(boot.excludedPaths).toContain("[unresolved-identity-path]");
+ // The raw invalid paths are never persisted and never value-bearing.
+ const metadataJson = JSON.stringify(persistedRow.metadata);
+ expect(metadataJson).not.toContain("bad path!");
+ expect(metadataJson).not.toContain("a..b");
+ expect(Object.keys(boot.values)).not.toContain("bad path!");
+ expect(Object.keys(boot.values)).not.toContain("a..b");
+
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({ status: 409 });
+ const relinked = await service.relinkActiveTemplate({
+ environmentId,
+ confirmBootSourceDrift: true,
+ actor: relinkActor,
+ companyId,
+ });
+ expect(relinked.classification).toBe("unclassified");
+ });
+
+ it("fails closed for prototype-key driver identity paths and never persists them", async () => {
+ const { companyId, environmentId } = await seed({ manifest: prototypeKeyIdentityPluginManifest(), config: {
+ provider: "fake-prototype-key-plugin",
+ image: "fake:base",
+ reuseLease: false,
+ } });
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
+ const promoted = await service.finishSetupSession({ sessionId: started.session.id });
+
+ expect((promoted.template.metadata as Record).bootRelevantConfig).toBeUndefined();
+ const persistedRow = await activeTemplateRow(environmentId);
+ const boot = (persistedRow.metadata as Record).bootRelevantConfig as {
+ values: Record;
+ excludedPaths: string[];
+ };
+ // Every prototype key fails canonicalization and records only the marker.
+ expect(boot.excludedPaths).toContain("[unresolved-identity-path]");
+ // No raw prototype-key path and no value for one persists in the snapshot.
+ expect(boot.excludedPaths).not.toContain("__proto__");
+ expect(boot.excludedPaths).not.toContain("constructor");
+ expect(boot.excludedPaths).not.toContain("prototype");
+ expect(Object.keys(boot.values)).not.toContain("__proto__");
+ expect(Object.keys(boot.values)).not.toContain("constructor");
+ expect(Object.keys(boot.values)).not.toContain("prototype");
+ // The prototype of the values map stays clean; no key leaked onto it.
+ expect(Object.getPrototypeOf(boot.values)).toBe(Object.prototype);
+ const metadataJson = JSON.stringify(persistedRow.metadata);
+ expect(metadataJson).not.toContain("__proto__");
+ expect(metadataJson).not.toContain("nested.__proto__");
+
+ // An unflagged relink fails closed with 409 and the unclassified class.
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } });
+ // A confirmed relink succeeds and writes the relinked activity entry.
+ const relinked = await service.relinkActiveTemplate({
+ environmentId,
+ confirmBootSourceDrift: true,
+ actor: relinkActor,
+ companyId,
+ });
+ expect(relinked.classification).toBe("unclassified");
+ const activities = await relinkActivityRows(environmentId);
+ const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked");
+ expect(relinkActivity).toBeDefined();
+ });
+
+ it("fails closed when the provider adds a boot-relevant identity path after capture", async () => {
+ const { companyId, environmentId } = await seed({ config: {
+ provider: "fake-plugin",
+ image: "fake:base",
+ region: "us",
+ reuseLease: false,
+ } });
+ const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
+ const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
+ await service.finishSetupSession({ sessionId: started.session.id });
+
+ // A knob-only region change detaches the template. On its own it would
+ // relink without confirmation.
+ await db.update(environments)
+ .set({ config: { provider: "fake-plugin", image: "fake:base", region: "eu", reuseLease: false } })
+ .where(eq(environments.id, environmentId));
+
+ // The provider now declares `region` a boot-relevant identity path. The
+ // capture snapshot never covered it, so the server cannot verify the boot
+ // source and must not classify the drift as knob-only.
+ const manifest = pluginManifest();
+ const nextManifest = {
+ ...manifest,
+ environmentDrivers: [
+ { ...manifest.environmentDrivers[0], templateIdentityPaths: ["apiUrl", "region"] },
+ ],
+ };
+ await db.update(plugins)
+ .set({ manifestJson: nextManifest })
+ .where(eq(plugins.pluginKey, manifest.id));
+
+ await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
+ .rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } });
+ const relinked = await service.relinkActiveTemplate({
+ environmentId,
+ confirmBootSourceDrift: true,
+ actor: relinkActor,
+ companyId,
+ });
+ expect(relinked.classification).toBe("unclassified");
+ });
+});
diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts
index 834f1e2407a..38fe085738f 100644
--- a/server/src/routes/environments.ts
+++ b/server/src/routes/environments.ts
@@ -11,6 +11,7 @@ import {
resolveDeclaredSandboxCapabilities,
redactEnvironmentCustomImageSetupSession,
redactEnvironmentCustomImageTemplate,
+ relinkEnvironmentCustomImageTemplateSchema,
startEnvironmentCustomImageSetupSessionSchema,
type EnvironmentDeleteBlastRadius,
updateEnvironmentSchema,
@@ -952,6 +953,31 @@ export function environmentRoutes(
res.json(result);
});
+ router.post(
+ "/environments/:environmentId/custom-image-template/relink",
+ validate(relinkEnvironmentCustomImageTemplateSchema),
+ async (req, res) => {
+ assertCanAccessInstanceEnvironments(req);
+ const companyId = await resolveCustomImageCompanyId(req);
+ const actor = getActorInfo(req);
+ // The service classifies drift, re-stamps the fingerprint, and writes the
+ // activity row in one transaction. The route never classifies.
+ const result = await customImages.relinkActiveTemplate({
+ environmentId: req.params.environmentId as string,
+ confirmBootSourceDrift: req.body.confirmBootSourceDrift === true,
+ actor: {
+ actorType: actor.actorType,
+ actorId: actor.actorId,
+ agentId: actor.agentId,
+ runId: actor.runId,
+ agentApiKeyId: actor.agentApiKeyId,
+ },
+ companyId,
+ });
+ res.json(result);
+ },
+ );
+
router.delete("/environments/:environmentId/custom-image-template", async (req, res) => {
assertCanAccessInstanceEnvironments(req);
const companyId = await resolveCustomImageCompanyId(req);
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 9e47791a5fc..e225b84da03 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -112,6 +112,7 @@ import {
environmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTemplateSchema,
finishEnvironmentCustomImageSetupSessionSchema,
+ relinkEnvironmentCustomImageTemplateSchema,
updateEnvironmentSchema,
probeEnvironmentConfigSchema,
startEnvironmentCustomImageSetupSessionSchema,
@@ -635,6 +636,11 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({
supersededTemplate: environmentCustomImageTemplateSchema,
}).strict();
+const environmentCustomImageTemplateRelinkResultSchema = z.object({
+ template: environmentCustomImageTemplateSchema,
+ classification: z.enum(["knob_only", "boot_source_drift", "unclassified"]),
+}).strict();
+
const workTimelineQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
@@ -5585,6 +5591,26 @@ registry.registerPath({
},
});
+registry.registerPath({
+ method: "post",
+ path: "/api/environments/{environmentId}/custom-image-template/relink",
+ tags: ["environments"],
+ summary: "Relink a detached environment customImage template to the current config",
+ request: {
+ params: z.object({ environmentId: z.string() }),
+ query: environmentCustomImageCompanyQuerySchema,
+ body: jsonBody(relinkEnvironmentCustomImageTemplateSchema),
+ },
+ responses: {
+ 200: r.ok(environmentCustomImageTemplateRelinkResultSchema),
+ 400: r.badRequest,
+ 401: r.unauthorized,
+ 403: r.forbidden,
+ 404: r.notFound,
+ 409: r.conflict,
+ },
+});
+
registry.registerPath({
method: "delete",
path: "/api/environments/{environmentId}/custom-image-template",
diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts
index 254169290c8..996770ef3f7 100644
--- a/server/src/services/environment-custom-image-runtime.ts
+++ b/server/src/services/environment-custom-image-runtime.ts
@@ -219,6 +219,313 @@ export function classifyEnvironmentCustomImageConfigChange(input: {
return "relinkable";
}
+// --- Operator relink action (server-owned boot-source classification) --------
+//
+// A saved config change moves the fingerprint and detaches an otherwise-valid
+// template. The relink action re-stamps the fingerprint, but only after the
+// server decides the boot source did not change. To decide that without the
+// capture-time config, the capture persists a small, server-owned snapshot of
+// the boot-relevant fields. The relink then compares that snapshot to the
+// current config. The comparison runs on the server; the client never
+// classifies.
+
+export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY = "bootRelevantConfig";
+export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION = 1;
+// A driver-declared identity path that fails canonicalization is never trusted
+// and never persisted verbatim. It records this fixed marker instead, which
+// forces the fail-closed `unclassified` result at relink time.
+export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_UNRESOLVED_PATH = "[unresolved-identity-path]";
+
+const BOOT_RELEVANT_CONFIG_MAX_PATH_DEPTH = 8;
+const BOOT_RELEVANT_CONFIG_PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
+// Prototype keys have an identifier shape but never name an own config field.
+// A driver that declares one is hostile or broken. Reject it so the path fails
+// closed as an unresolved identity path.
+const BOOT_RELEVANT_CONFIG_RESERVED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
+
+export type EnvironmentCustomImageRelinkClassification =
+ | "knob_only"
+ | "boot_source_drift"
+ | "unclassified";
+
+/**
+ * Server-owned snapshot of the boot-relevant config fields at capture time. It
+ * carries the field values that decide the boot source, with every secret-ref
+ * path and every unresolvable driver path excluded (path name only, no value).
+ */
+export interface EnvironmentCustomImageBootRelevantConfig {
+ version: typeof ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION;
+ provider: string;
+ bindingField: string;
+ values: Record;
+ excludedPaths: string[];
+}
+
+export interface EnvironmentCustomImageDriftedPath {
+ path: string;
+ from?: unknown;
+ to?: unknown;
+}
+
+/**
+ * Canonicalizes a config path as a bounded dot-path. Each segment must be a
+ * plain identifier and the depth is bounded. An invalid, ambiguous, or
+ * unresolvable path returns `null`; the caller must never persist the raw
+ * driver-supplied string.
+ */
+export function canonicalizeEnvironmentCustomImageConfigPath(raw: unknown): string | null {
+ if (typeof raw !== "string") return null;
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ const segments = trimmed.split(".");
+ if (segments.length === 0 || segments.length > BOOT_RELEVANT_CONFIG_MAX_PATH_DEPTH) return null;
+ for (const segment of segments) {
+ if (!BOOT_RELEVANT_CONFIG_PATH_SEGMENT_RE.test(segment)) return null;
+ if (BOOT_RELEVANT_CONFIG_RESERVED_SEGMENTS.has(segment)) return null;
+ }
+ return segments.join(".");
+}
+
+/**
+ * Reports whether a candidate path overlaps any secret-ref path by exact,
+ * ancestor, or descendant containment.
+ */
+function environmentCustomImageConfigPathOverlapsSecret(
+ candidate: string,
+ secretPaths: Iterable,
+): boolean {
+ for (const raw of secretPaths) {
+ const secret = canonicalizeEnvironmentCustomImageConfigPath(raw);
+ if (!secret) continue;
+ if (candidate === secret) return true;
+ if (candidate.startsWith(`${secret}.`)) return true;
+ if (secret.startsWith(`${candidate}.`)) return true;
+ }
+ return false;
+}
+
+/**
+ * The current boot-relevant path contract a provider driver declares. It names
+ * the config binding and the driver identity paths that decide the boot source
+ * now. A relink compares a persisted snapshot against this contract to detect a
+ * provider change since capture.
+ */
+export interface EnvironmentCustomImageBootRelevantContract {
+ binding: EnvironmentCustomImageRuntimeConfigBinding;
+ templateIdentityPaths?: Iterable;
+}
+
+/**
+ * Computes the canonical candidate boot-relevant paths for a binding and its
+ * driver identity paths. Both the capture snapshot and the relink staleness
+ * check use this function, so they share one path set. `hasUnresolved` is true
+ * when any raw path fails canonicalization.
+ */
+export function environmentCustomImageBootRelevantCandidatePaths(
+ contract: EnvironmentCustomImageBootRelevantContract,
+): { canonicalPaths: string[]; hasUnresolved: boolean } {
+ const canonicalPaths: string[] = [];
+ const seen = new Set();
+ let hasUnresolved = false;
+ const rawCandidatePaths = [
+ contract.binding.field,
+ ...contract.binding.unsetFields,
+ ...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS,
+ ...(contract.templateIdentityPaths ?? []),
+ ];
+ for (const rawPath of rawCandidatePaths) {
+ const canonical = canonicalizeEnvironmentCustomImageConfigPath(rawPath);
+ if (!canonical) {
+ hasUnresolved = true;
+ continue;
+ }
+ if (seen.has(canonical)) continue;
+ seen.add(canonical);
+ canonicalPaths.push(canonical);
+ }
+ return { canonicalPaths, hasUnresolved };
+}
+
+/**
+ * Builds the capture-time boot-relevant snapshot from the parsed config only.
+ * Candidate paths are the runtime binding field, the binding unset fields, the
+ * standard boot-source fields, and every driver identity path. A secret-ref
+ * overlap or an unresolvable path is recorded in `excludedPaths` with no value.
+ */
+export function buildEnvironmentCustomImageBootRelevantConfig(input: {
+ config: SandboxEnvironmentConfig;
+ binding: EnvironmentCustomImageRuntimeConfigBinding;
+ templateIdentityPaths?: Iterable;
+ secretRefExcludePaths?: Iterable;
+}): EnvironmentCustomImageBootRelevantConfig {
+ const config = input.config as Record;
+ const secretPaths = [...(input.secretRefExcludePaths ?? [])];
+ // A null-prototype map is a second guard: a reserved segment assignment can
+ // never mutate the prototype, so a hostile path cannot vanish from `values`.
+ const values: Record = Object.create(null);
+ const excludedPaths = new Set();
+
+ const { canonicalPaths, hasUnresolved } = environmentCustomImageBootRelevantCandidatePaths({
+ binding: input.binding,
+ templateIdentityPaths: input.templateIdentityPaths,
+ });
+ if (hasUnresolved) {
+ excludedPaths.add(ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_UNRESOLVED_PATH);
+ }
+ for (const canonical of canonicalPaths) {
+ if (environmentCustomImageConfigPathOverlapsSecret(canonical, secretPaths)) {
+ excludedPaths.add(canonical);
+ continue;
+ }
+ const value = readConfigValueAtPath(config, canonical);
+ // An absent field is stored as `null` so that "absent then, absent now"
+ // compares equal at relink time.
+ values[canonical] = value === undefined ? null : value;
+ }
+ return {
+ version: ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION,
+ provider: input.config.provider,
+ bindingField: input.binding.field,
+ values,
+ excludedPaths: [...excludedPaths],
+ };
+}
+
+/**
+ * Reports whether a persisted snapshot no longer matches the current provider
+ * boot-relevant contract. A provider plugin can change its config binding or
+ * its identity paths after capture. When it does, a field that is boot-relevant
+ * now but absent from the snapshot would compare equal by absence and hide real
+ * drift. The check fails closed on:
+ *
+ * - a binding field that differs from the captured `bindingField`;
+ * - a current identity path the snapshot never covered (neither a value nor an
+ * excluded path);
+ * - a current contract that itself carries an unresolvable identity path.
+ */
+export function environmentCustomImageBootRelevantSnapshotIsStale(input: {
+ bootRelevantConfig: EnvironmentCustomImageBootRelevantConfig;
+ currentContract: EnvironmentCustomImageBootRelevantContract;
+}): boolean {
+ const boot = input.bootRelevantConfig;
+ if (input.currentContract.binding.field !== boot.bindingField) return true;
+ const { canonicalPaths, hasUnresolved } = environmentCustomImageBootRelevantCandidatePaths(
+ input.currentContract,
+ );
+ if (hasUnresolved) return true;
+ const covered = new Set([
+ ...Object.keys(boot.values),
+ ...boot.excludedPaths,
+ ]);
+ return canonicalPaths.some((path) => !covered.has(path));
+}
+
+/**
+ * Reads and validates the persisted boot-relevant snapshot. Returns `null` for
+ * a legacy template with no snapshot or for a malformed shape; both classify as
+ * `unclassified` (fail closed).
+ */
+export function readEnvironmentCustomImageBootRelevantConfig(
+ metadata: Record | null | undefined,
+): EnvironmentCustomImageBootRelevantConfig | null {
+ const raw = metadata?.[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY];
+ if (!isRecord(raw)) return null;
+ if (raw.version !== ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION) return null;
+ if (typeof raw.provider !== "string" || typeof raw.bindingField !== "string") return null;
+ if (!isRecord(raw.values)) return null;
+ if (!Array.isArray(raw.excludedPaths)) return null;
+ const excludedPaths = raw.excludedPaths.filter(
+ (entry): entry is string => typeof entry === "string",
+ );
+ return {
+ version: ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION,
+ provider: raw.provider,
+ bindingField: raw.bindingField,
+ values: raw.values,
+ excludedPaths,
+ };
+}
+
+/**
+ * Classifies the drift between the capture-time boot-relevant snapshot and the
+ * current parsed config.
+ *
+ * - `knob_only`: the snapshot is present, no path was excluded, and every value
+ * still matches. The fingerprint can be re-stamped without confirmation.
+ * - `boot_source_drift`: a value-bearing path differs. The operator must
+ * confirm before the re-stamp.
+ * - `unclassified`: no snapshot, an excluded path, a provider mismatch, or a
+ * snapshot that no longer matches the current provider contract. The server
+ * cannot verify the boot source; the operator must confirm (fail closed).
+ *
+ * `driftedPaths` carries raw `from`/`to` values only for value-bearing paths
+ * that passed containment at capture. Excluded paths carry the path name only.
+ *
+ * `currentContract` is the current provider boot-relevant contract. When the
+ * caller cannot resolve the driver, it passes `null`; the server then cannot
+ * verify the boot source and the result is `unclassified` (fail closed).
+ */
+export function classifyEnvironmentCustomImageBootRelevantDrift(input: {
+ bootRelevantConfig: EnvironmentCustomImageBootRelevantConfig | null;
+ currentConfig: SandboxEnvironmentConfig;
+ currentContract: EnvironmentCustomImageBootRelevantContract | null;
+}): {
+ classification: EnvironmentCustomImageRelinkClassification;
+ driftedPaths: EnvironmentCustomImageDriftedPath[];
+} {
+ const boot = input.bootRelevantConfig;
+ if (!boot) return { classification: "unclassified", driftedPaths: [] };
+ // Without the current provider contract the boot source cannot be verified
+ // against a possible identity-path change; fail closed.
+ if (!input.currentContract) return { classification: "unclassified", driftedPaths: [] };
+ // A provider change since capture (new binding or new identity path) makes
+ // the persisted snapshot untrustworthy; fail closed.
+ if (environmentCustomImageBootRelevantSnapshotIsStale({
+ bootRelevantConfig: boot,
+ currentContract: input.currentContract,
+ })) {
+ return { classification: "unclassified", driftedPaths: [] };
+ }
+ const current = input.currentConfig as Record;
+
+ const driftedPaths: EnvironmentCustomImageDriftedPath[] = [];
+ let hasValueDrift = false;
+ for (const [path, capturedValue] of Object.entries(boot.values)) {
+ const currentValue = readConfigValueAtPath(current, path);
+ if (stableStringify(capturedValue ?? null) !== stableStringify(currentValue ?? null)) {
+ hasValueDrift = true;
+ driftedPaths.push({ path, from: capturedValue ?? null, to: currentValue ?? null });
+ }
+ }
+ for (const path of boot.excludedPaths) {
+ driftedPaths.push({ path });
+ }
+ const providerMismatch = boot.provider !== input.currentConfig.provider;
+ if (boot.excludedPaths.length > 0 || providerMismatch) {
+ return { classification: "unclassified", driftedPaths };
+ }
+ if (hasValueDrift) {
+ return { classification: "boot_source_drift", driftedPaths };
+ }
+ return { classification: "knob_only", driftedPaths: [] };
+}
+
+/**
+ * Removes the server-only boot-relevant snapshot from a template's metadata.
+ * The snapshot exists to classify a later relink and holds raw boot-source
+ * config values. It must never reach an API response. The runtime keeps it in
+ * the persisted row and reads it straight from the row at relink time.
+ */
+export function stripInternalEnvironmentCustomImageTemplateMetadata(
+ metadata: Record | null | undefined,
+): Record | null {
+ if (!metadata) return null;
+ if (!(ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY in metadata)) return metadata;
+ const rest = { ...metadata };
+ delete rest[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY];
+ return rest;
+}
+
export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate {
return {
id: row.id,
@@ -234,7 +541,9 @@ export function environmentCustomImageTemplateFromRow(row: TemplateRow): Environ
capturedAt: row.capturedAt ?? null,
lastUsedAt: row.lastUsedAt ?? null,
supersededByTemplateId: row.supersededByTemplateId ?? null,
- metadata: row.metadata ?? null,
+ // The boot-relevant snapshot is server-internal; keep it out of every
+ // template response. The relink path reads it from the persisted row.
+ metadata: stripInternalEnvironmentCustomImageTemplateMetadata(row.metadata),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts
index f8a8449f2f9..d481ebfacbf 100644
--- a/server/src/services/environment-custom-images.ts
+++ b/server/src/services/environment-custom-images.ts
@@ -39,7 +39,11 @@ import {
import { environmentService } from "./environments.js";
import {
ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
+ ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY,
classifyEnvironmentCustomImageConfigChange,
+ classifyEnvironmentCustomImageBootRelevantDrift,
+ buildEnvironmentCustomImageBootRelevantConfig,
+ readEnvironmentCustomImageBootRelevantConfig,
fingerprintEnvironmentSandboxProviderConfig,
ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY,
defaultEnvironmentCustomImageRuntimeConfigBinding,
@@ -47,7 +51,9 @@ import {
normalizeEnvironmentCustomImageRuntimeConfigBinding,
environmentCustomImageTemplateFromRow,
readEnvironmentCustomImageTemplateKind as readTemplateKind,
+ type EnvironmentCustomImageRelinkClassification,
} from "./environment-custom-image-runtime.js";
+import { logActivity } from "./activity-log.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
const ACTIVE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const;
@@ -230,10 +236,10 @@ function sourceTemplateFromConfig(
return { sourceTemplateRef: null, sourceTemplateKind: null };
}
-async function resolveActiveTemplate(
+async function resolveActiveTemplateRow(
db: Db,
input: { environmentId: string; provider?: string | null },
-): Promise {
+): Promise {
const conditions = [
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.status, "active"),
@@ -241,12 +247,19 @@ async function resolveActiveTemplate(
if (input.provider) {
conditions.push(eq(environmentCustomImageTemplates.provider, input.provider));
}
- const row = await db
+ return db
.select()
.from(environmentCustomImageTemplates)
.where(and(...conditions))
.orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt))
.then((rows) => rows[0] ?? null);
+}
+
+async function resolveActiveTemplate(
+ db: Db,
+ input: { environmentId: string; provider?: string | null },
+): Promise {
+ const row = await resolveActiveTemplateRow(db, input);
return row ? environmentCustomImageTemplateFromRow(row) : null;
}
@@ -803,11 +816,14 @@ export function environmentCustomImageService(
const captured = await callProviderCapture({ session, previousTemplate: currentActive });
const environment = await requireEnvironment(session.environmentId);
const parsed = parseEnvironmentDriverConfig(environment);
+ const captureSecretRefExcludePaths = parsed.driver === "sandbox"
+ ? [...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider)]
+ : [];
const baseFingerprint = parsed.driver === "sandbox"
? fingerprintEnvironmentSandboxProviderConfig(parsed.config, {
excludePaths: [
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
- ...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider),
+ ...captureSecretRefExcludePaths,
],
})
: null;
@@ -821,6 +837,32 @@ export function environmentCustomImageService(
templateRefKind: captured.templateKind,
templateConfigBinding: provider.driver.templateConfigBinding,
});
+ // Server-owned boot-relevant snapshot from the parsed config only. It
+ // lets a later relink decide, without the capture-time config, whether
+ // the boot source changed. Secret-ref paths carry no value.
+ const bootRelevantConfig = parsed.driver === "sandbox"
+ ? buildEnvironmentCustomImageBootRelevantConfig({
+ config: parsed.config,
+ binding: runtimeConfigBinding,
+ templateIdentityPaths: provider.driver.templateIdentityPaths ?? [],
+ secretRefExcludePaths: captureSecretRefExcludePaths,
+ })
+ : null;
+ const baseTemplateMetadata = normalizeProviderMetadata({
+ ...(captured.metadata ?? {}),
+ ...(input.metadata ? { userMetadata: input.metadata } : {}),
+ ...persistedSetupMetadata(session.metadata),
+ [ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding,
+ }) ?? {};
+ // The boot-relevant snapshot bypasses the provider-metadata redactor:
+ // its config field names (for example `apiUrl`) would otherwise be
+ // redacted by key and the relink comparison could never match.
+ const templateMetadata = bootRelevantConfig
+ ? {
+ ...baseTemplateMetadata,
+ [ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY]: bootRelevantConfig,
+ }
+ : baseTemplateMetadata;
const now = input.now ?? new Date();
const templateRow = await db.transaction(async (tx) => {
const templateId = randomUUID();
@@ -851,12 +893,7 @@ export function environmentCustomImageService(
createdByUserId: session.startedByUserId,
createdByAgentId: session.startedByAgentId,
capturedAt: now,
- metadata: normalizeProviderMetadata({
- ...(captured.metadata ?? {}),
- ...(input.metadata ? { userMetadata: input.metadata } : {}),
- ...persistedSetupMetadata(session.metadata),
- [ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding,
- }),
+ metadata: templateMetadata,
createdAt: now,
updatedAt: now,
})
@@ -999,6 +1036,138 @@ export function environmentCustomImageService(
};
},
+ /**
+ * Re-stamps the active template's source fingerprint so a detached-but-valid
+ * template applies again, without a sandbox boot or a new provider snapshot.
+ * The server classifies the drift between the capture-time boot-relevant
+ * snapshot and the current config. A `boot_source_drift` or `unclassified`
+ * result needs the `confirmBootSourceDrift` flag; the client never
+ * classifies. The re-stamp UPDATE and the activity row run in one
+ * transaction. Zero updated rows abort with a conflict and write no activity
+ * row.
+ */
+ relinkActiveTemplate: async (input: {
+ environmentId: string;
+ confirmBootSourceDrift?: boolean;
+ actor: {
+ actorType: "agent" | "user" | "system" | "plugin";
+ actorId: string;
+ agentId?: string | null;
+ runId?: string | null;
+ agentApiKeyId?: string | null;
+ };
+ companyId: string;
+ now?: Date;
+ }): Promise<{
+ template: EnvironmentCustomImageTemplate;
+ classification: EnvironmentCustomImageRelinkClassification;
+ }> => {
+ const environment = await requireEnvironment(input.environmentId);
+ const parsed = parseEnvironmentDriverConfig(environment);
+ if (parsed.driver !== "sandbox") {
+ throw unprocessable("Environment customImage relink is only supported for sandbox environments.");
+ }
+ const activeRow = await resolveActiveTemplateRow(db, {
+ environmentId: input.environmentId,
+ provider: parsed.config.provider,
+ });
+ if (!activeRow) throw notFound("Active environment customImage template not found");
+ const active = environmentCustomImageTemplateFromRow(activeRow);
+
+ const secretRefExcludePaths = parsed.config.provider === "fake"
+ ? []
+ : [...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider)];
+ // Resolve the current provider contract so the classifier can reject a
+ // snapshot captured against a different binding or identity-path set. A
+ // driver that no longer resolves fails closed (null contract).
+ const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({
+ db,
+ driverKey: active.provider,
+ });
+ const currentContract = resolvedDriver
+ ? {
+ binding: templateConfigBindingFromDriver({
+ templateRefKind: active.templateKind,
+ templateConfigBinding: resolvedDriver.driver.templateConfigBinding,
+ }),
+ templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [],
+ }
+ : null;
+ // The persisted snapshot is server-internal; read it from the row, not the
+ // sanitized template response.
+ const drift = classifyEnvironmentCustomImageBootRelevantDrift({
+ bootRelevantConfig: readEnvironmentCustomImageBootRelevantConfig(activeRow.metadata),
+ currentConfig: parsed.config,
+ currentContract,
+ });
+
+ const confirmBootSourceDrift = input.confirmBootSourceDrift === true;
+ if (drift.classification !== "knob_only" && !confirmBootSourceDrift) {
+ throw conflict(
+ drift.classification === "boot_source_drift"
+ ? "The base image changed since this template was captured. Confirm the relink to keep the captured snapshot."
+ : "The server cannot verify the boot source for this template. Confirm the relink to keep the captured snapshot.",
+ {
+ classification: drift.classification,
+ driftedPaths: drift.driftedPaths,
+ },
+ );
+ }
+
+ const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(parsed.config, {
+ excludePaths: [
+ ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
+ ...secretRefExcludePaths,
+ ],
+ });
+ const now = input.now ?? new Date();
+ // Activity details carry the sanitized classification and canonical path
+ // names only. They never carry a fingerprint or a config value.
+ const activityDetails = {
+ environmentId: input.environmentId,
+ templateId: active.id,
+ provider: active.provider,
+ classification: drift.classification,
+ driftedPaths: drift.driftedPaths.map((entry) => entry.path),
+ confirmBootSourceDrift,
+ };
+
+ const row = await db.transaction(async (tx) => {
+ const updated = await tx
+ .update(environmentCustomImageTemplates)
+ .set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now })
+ .where(and(
+ eq(environmentCustomImageTemplates.id, active.id),
+ eq(environmentCustomImageTemplates.status, "active"),
+ eq(environmentCustomImageTemplates.environmentId, input.environmentId),
+ eq(environmentCustomImageTemplates.provider, active.provider),
+ ))
+ .returning()
+ .then((rows) => rows[0] ?? null);
+ if (!updated) {
+ throw conflict("Active environment customImage template changed before relink; retry.");
+ }
+ await logActivity(tx as unknown as Db, {
+ companyId: input.companyId,
+ actorType: input.actor.actorType,
+ actorId: input.actor.actorId,
+ agentId: input.actor.agentId ?? null,
+ runId: input.actor.runId ?? null,
+ agentApiKeyId: input.actor.agentApiKeyId ?? null,
+ action: "environment.custom_image_template.relinked",
+ entityType: "environment",
+ entityId: input.environmentId,
+ details: activityDetails,
+ });
+ return updated;
+ });
+
+ return {
+ template: environmentCustomImageTemplateFromRow(row),
+ classification: drift.classification,
+ };
+ },
+
rollbackTemplate: async (input: {
environmentId: string;
now?: Date;
diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts
index 5b1bc6e05f2..46ca219291f 100644
--- a/ui/src/api/environments.ts
+++ b/ui/src/api/environments.ts
@@ -54,6 +54,32 @@ export interface EnvironmentCustomImageRollbackResult {
supersededTemplate: EnvironmentCustomImageTemplate;
}
+export type EnvironmentCustomImageRelinkClassification =
+ | "knob_only"
+ | "boot_source_drift"
+ | "unclassified";
+
+export interface EnvironmentCustomImageRelinkResult {
+ template: EnvironmentCustomImageTemplate;
+ classification: EnvironmentCustomImageRelinkClassification;
+}
+
+export interface EnvironmentCustomImageDriftedPath {
+ path: string;
+ from?: unknown;
+ to?: unknown;
+}
+
+/**
+ * The 409 conflict body a relink returns when the server cannot re-stamp without
+ * an operator confirmation. `driftedPaths` carries `from`/`to` only for paths
+ * that passed the secret containment check; excluded paths carry the name only.
+ */
+export interface EnvironmentCustomImageRelinkConflict {
+ classification: Exclude;
+ driftedPaths: EnvironmentCustomImageDriftedPath[];
+}
+
function companyIdQuery(companyId: string): string {
return `companyId=${encodeURIComponent(companyId)}`;
}
@@ -162,6 +188,15 @@ export const environmentsApi = {
`/environments/${environmentId}/custom-image-template/rollback?${companyIdQuery(companyId)}`,
{},
),
+ relinkCustomImageTemplate: (
+ environmentId: string,
+ companyId: string,
+ options: { confirmBootSourceDrift?: boolean } = {},
+ ) =>
+ api.post(
+ `/environments/${environmentId}/custom-image-template/relink?${companyIdQuery(companyId)}`,
+ { confirmBootSourceDrift: options.confirmBootSourceDrift === true },
+ ),
disableCustomImageTemplate: (
environmentId: string,
companyId: string,
diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx
index cd8a7e52b06..13b27924ebb 100644
--- a/ui/src/pages/CompanyEnvironments.test.tsx
+++ b/ui/src/pages/CompanyEnvironments.test.tsx
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CompanyEnvironments } from "./CompanyEnvironments";
+import { ApiError } from "@/api/client";
const xtermMocks = vi.hoisted(() => {
class MockTerminal {
@@ -138,6 +139,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({
finishCustomImageSetupSession: vi.fn(),
cancelCustomImageSetupSession: vi.fn(),
rollbackCustomImageTemplate: vi.fn(),
+ relinkCustomImageTemplate: vi.fn(),
disableCustomImageTemplate: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
@@ -432,6 +434,10 @@ describe("CompanyEnvironments — test provider button", () => {
activeTemplate: createTemplate({ id: "template-previous" }),
supersededTemplate: createTemplate({ id: "template-current", status: "superseded" }),
});
+ mockEnvironmentsApi.relinkCustomImageTemplate.mockResolvedValue({
+ template: createTemplate({ id: "template-relinked" }),
+ classification: "knob_only",
+ });
mockEnvironmentsApi.disableCustomImageTemplate.mockResolvedValue(
createTemplate({ status: "revoked" }),
);
@@ -1391,6 +1397,136 @@ describe("CompanyEnvironments — test provider button", () => {
});
});
+ function setupOutOfSyncTemplatePanel() {
+ mockEnvironmentsApi.list.mockResolvedValue([
+ { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
+ ]);
+ mockEnvironmentsApi.capabilities.mockResolvedValue({
+ adapters: [],
+ drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
+ sandboxProviders: {
+ daytona: {
+ status: "supported",
+ supportsSavedProbe: true,
+ supportsUnsavedProbe: true,
+ supportsRunExecution: true,
+ supportsReusableLeases: true,
+ supportsInteractiveSetup: true,
+ interactiveSetupConnectionTypes: ["ssh"],
+ supportsTemplateCapture: true,
+ supportsTemplateDelete: true,
+ displayName: "Daytona",
+ },
+ },
+ });
+ mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
+ activeTemplate: createTemplate({ id: "template-active" }),
+ activeTemplateMatchesConfig: false,
+ activeSession: null,
+ latestSession: null,
+ });
+ }
+
+ it("relinks an out-of-sync template and names both remedies in the copy", async () => {
+ root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ setupOutOfSyncTemplatePanel();
+
+ await act(async () => {
+ root!.render(renderCompanyEnvironments(queryClient));
+ });
+ await flushReact();
+
+ await act(async () => click(editButtons(container)[0]));
+ await waitForAssertion(() => {
+ const dialog = getEnvironmentFormPage()!;
+ expect(dialog.textContent).toContain("relink this image or capture a new one");
+ expect(findButton(dialog, "Relink")).toBeTruthy();
+ });
+
+ await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink")));
+ await waitForAssertion(() => {
+ expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
+ });
+ });
+
+ it("confirms boot-source drift before re-sending the relink with the flag", async () => {
+ root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ setupOutOfSyncTemplatePanel();
+ mockEnvironmentsApi.relinkCustomImageTemplate
+ .mockRejectedValueOnce(new ApiError("Confirm the relink.", 409, {
+ error: "Confirm the relink.",
+ details: {
+ classification: "boot_source_drift",
+ driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }],
+ },
+ }))
+ .mockResolvedValueOnce({
+ template: createTemplate({ id: "template-relinked" }),
+ classification: "boot_source_drift",
+ });
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+
+ try {
+ await act(async () => {
+ root!.render(renderCompanyEnvironments(queryClient));
+ });
+ await flushReact();
+ await act(async () => click(editButtons(container)[0]));
+ await waitForAssertion(() => {
+ expect(findButton(getEnvironmentFormPage()!, "Relink")).toBeTruthy();
+ });
+
+ await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink")));
+ await waitForAssertion(() => {
+ expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenNthCalledWith(1, "env-1", "company-1");
+ expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenNthCalledWith(
+ 2,
+ "env-1",
+ "company-1",
+ { confirmBootSourceDrift: true },
+ );
+ });
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
+ expect(confirmSpy.mock.calls[0]![0]).toContain("image fake:base -> fake:other");
+ } finally {
+ confirmSpy.mockRestore();
+ }
+ });
+
+ it("does not re-send the relink when the operator declines the confirmation", async () => {
+ root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ setupOutOfSyncTemplatePanel();
+ mockEnvironmentsApi.relinkCustomImageTemplate.mockRejectedValueOnce(new ApiError("Cannot verify.", 409, {
+ error: "Cannot verify.",
+ details: { classification: "unclassified", driftedPaths: [{ path: "apiUrl" }] },
+ }));
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
+
+ try {
+ await act(async () => {
+ root!.render(renderCompanyEnvironments(queryClient));
+ });
+ await flushReact();
+ await act(async () => click(editButtons(container)[0]));
+ await waitForAssertion(() => {
+ expect(findButton(getEnvironmentFormPage()!, "Relink")).toBeTruthy();
+ });
+
+ await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink")));
+ await waitForAssertion(() => {
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
+ });
+ expect(confirmSpy.mock.calls[0]![0]).toContain("cannot verify the boot source");
+ expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledTimes(1);
+ expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledWith("env-1", "company-1");
+ } finally {
+ confirmSpy.mockRestore();
+ }
+ });
+
it("offers the implicit Local option in the default picker by default", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx
index f974caddf86..570d0d0dd7c 100644
--- a/ui/src/pages/CompanyEnvironments.tsx
+++ b/ui/src/pages/CompanyEnvironments.tsx
@@ -6,7 +6,7 @@ import {
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ArrowLeft, Check, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
+import { ArrowLeft, Check, Link2, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTermTerminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
@@ -21,9 +21,11 @@ import {
import {
environmentsApi,
type EnvironmentCustomImageConnectionPayload,
+ type EnvironmentCustomImageRelinkConflict,
type EnvironmentCustomImageSetupSessionResult,
type EnvironmentUpdateResult,
} from "@/api/environments";
+import { ApiError } from "@/api/client";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
@@ -761,6 +763,37 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"])
}
}
+// The operator declined the drift confirmation prompt. It is not a failure, so
+// the relink mutation stays quiet instead of showing an error toast.
+class RelinkConfirmationDeclined extends Error {
+ constructor() {
+ super("relink confirmation declined");
+ this.name = "RelinkConfirmationDeclined";
+ }
+}
+
+function formatRelinkDriftValue(value: unknown): string {
+ if (value === null || value === undefined) return "(none)";
+ if (typeof value === "string") return value;
+ return JSON.stringify(value);
+}
+
+// Turns the sanitized 409 drift body into the operator warning. Value-bearing
+// drift shows the changed field; an unclassified result warns that the snapshot
+// will override the current base image.
+function relinkDriftWarning(conflict: EnvironmentCustomImageRelinkConflict): string {
+ if (conflict.classification === "boot_source_drift") {
+ const valued = conflict.driftedPaths.find(
+ (entry) => entry.from !== undefined || entry.to !== undefined,
+ );
+ if (valued) {
+ return `The base image changed: ${valued.path} ${formatRelinkDriftValue(valued.from)} -> ${formatRelinkDriftValue(valued.to)}.`;
+ }
+ return "The base image changed since this image was captured.";
+ }
+ return "The server cannot verify the boot source; the snapshot will override the current base image.";
+}
+
function EnvironmentImageTemplatePanel({
environment,
companyId,
@@ -909,6 +942,50 @@ function EnvironmentImageTemplatePanel({
},
});
+ const relinkTemplateMutation = useMutation({
+ // The route is called without the flag first. A 409 carries the sanitized
+ // drift detail; the operator must confirm before the flagged retry.
+ mutationFn: async () => {
+ try {
+ return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId);
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 409) {
+ const conflict = (error.body as { details?: EnvironmentCustomImageRelinkConflict } | null)?.details;
+ const warning = conflict ? relinkDriftWarning(conflict) : error.message;
+ if (!window.confirm(`${warning}\n\nRelink this image anyway?`)) {
+ throw new RelinkConfirmationDeclined();
+ }
+ return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId, {
+ confirmBootSourceDrift: true,
+ });
+ }
+ throw error;
+ }
+ },
+ onSuccess: (result) => {
+ queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
+ activeTemplate: result.template,
+ activeTemplateMatchesConfig: true,
+ activeSession: current?.activeSession ?? null,
+ latestSession: current?.latestSession ?? null,
+ }));
+ invalidateOverview();
+ pushToast({
+ title: "Template relinked",
+ body: "Runs use the captured image again.",
+ tone: "success",
+ });
+ },
+ onError: (error) => {
+ if (error instanceof RelinkConfirmationDeclined) return;
+ pushToast({
+ title: "Failed to relink template",
+ body: error instanceof Error ? error.message : "Relink failed.",
+ tone: "error",
+ });
+ },
+ });
+
const disableTemplateMutation = useMutation({
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId),
onSuccess: (template) => {
@@ -983,6 +1060,7 @@ function EnvironmentImageTemplatePanel({
startSetupMutation.isPending ||
finishSetupMutation.isPending ||
cancelSetupMutation.isPending ||
+ relinkTemplateMutation.isPending ||
rollbackTemplateMutation.isPending ||
disableTemplateMutation.isPending;
@@ -1076,8 +1154,8 @@ function EnvironmentImageTemplatePanel({
data-testid={`custom-image-template-out-of-sync-${environment.id}`}
>
Not in use — the environment configuration changed since this image was
- captured. Runs fall back to the base configuration until you capture a new
- image.
+ captured. Runs fall back to the base configuration until you relink this
+ image or capture a new one.
) : null}
@@ -1091,6 +1169,16 @@ function EnvironmentImageTemplatePanel({
Refresh
+
Providing a signal does not disable the default timeout; pass
timeout: 0 to disable it.
🖼️ WebP screenshots
expect(page).toHaveScreenshot()
and expect(locator).toHaveScreenshot()
can now store snapshots in the WebP format — just give the snapshot a
.webp name:
// Visual comparisons store the golden snapshot as
lossless WebP.
await expect(page).toHaveScreenshot('homepage.webp');
// Standalone screenshots can trade quality for size with lossy WebP.
await page.screenshot({ path: 'homepage.webp', quality: 50 });
</tr></table>
... (truncated)
Commits
26a9e47
cherry-pick(#42043):
docs: release notes for v1.62 Python, Java, and .NET (#4...
0a81d5d
cherry-pick(#42040):
docs(release-notes): mention the isolated headless clipb...
8376826
cherry-pick(#42034):
fix(aria): keep icon-only clickable elements in ai snaps...
66c5cc9
chore: mark v1.62.1 (#42020)
9672bc3
cherry-pick(#42009):
fix(types): support branded primitives in evaluate argum...
4325804
cherry-pick(#41988):
fix(aria): preserve names from collapsed text contributors
9632f8e
cherry-pick(#42005):
fix(tsconfig): do not throw when
"extends"/"references" ...
e3950d9
chore: mark v1.62.0 (#41981)
f07e0f7
cherry-pick(#41940):
docs: release notes for v1.62 (#41967)
05a306c
cherry-pick(#41964):
Revert "feat(routeFromHar): add interceptAPIRequests opt...
- Additional commits viewable in compare
view
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package.json | 2 +-
pnpm-lock.yaml | 32 ++++++++++++++++----------------
2 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/package.json b/package.json
index 7ef3cad63f1..6d4aacd6247 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs"
},
"devDependencies": {
- "@playwright/test": "^1.61.1",
+ "@playwright/test": "^1.62.1",
"cross-env": "^10.1.0",
"esbuild": "^0.28.1",
"typescript": "^5.7.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b0313d7eec7..7d11ed347cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,8 +32,8 @@ importers:
.:
devDependencies:
'@playwright/test':
- specifier: ^1.61.1
- version: 1.61.1
+ specifier: ^1.62.1
+ version: 1.62.1
cross-env:
specifier: ^10.1.0
version: 10.1.0
@@ -3316,9 +3316,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@playwright/test@1.61.1':
- resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
- engines: {node: '>=18'}
+ '@playwright/test@1.62.1':
+ resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
+ engines: {node: '>=20'}
hasBin: true
'@preact/signals-core@1.14.4':
@@ -7542,14 +7542,14 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
- playwright-core@1.61.1:
- resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
- engines: {node: '>=18'}
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
hasBin: true
- playwright@1.61.1:
- resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
- engines: {node: '>=18'}
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
hasBin: true
points-on-curve@0.2.0:
@@ -10974,9 +10974,9 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@playwright/test@1.61.1':
+ '@playwright/test@1.62.1':
dependencies:
- playwright: 1.61.1
+ playwright: 1.62.1
'@preact/signals-core@1.14.4': {}
@@ -15738,11 +15738,11 @@ snapshots:
pkce-challenge@5.0.1: {}
- playwright-core@1.61.1: {}
+ playwright-core@1.62.1: {}
- playwright@1.61.1:
+ playwright@1.62.1:
dependencies:
- playwright-core: 1.61.1
+ playwright-core: 1.62.1
optionalDependencies:
fsevents: 2.3.2
From 1b86af995ff4b69b56cdf170f753e84c5af33042 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:52:23 -0700
Subject: [PATCH 08/39] build(deps-dev): bump @storybook/addon-docs from 10.5.0
to 10.5.8 (#11513)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps
[@storybook/addon-docs](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/docs)
from 10.5.0 to 10.5.8.
Release notes
Sourced from @storybook/addon-docs's
releases.
v10.5.8
10.5.8
v10.5.7
10.5.7
v10.5.6
10.5.6
- Dependencies: Pin `@testing-library/jest-dom` to `6.9.1` - #35614,
thanks
@ndelangen!
- ESLint Plugin: Add plugin meta and document oxlint usage - #35655,
thanks
@yannbf!
- Vue: Skip docgen for module ids carrying a query - #35598,
thanks
@seanogdev!
v10.5.5
10.5.5
v10.5.4
10.5.4
v10.5.3
10.5.3
v10.5.2
10.5.2
... (truncated)
Changelog
Sourced from @storybook/addon-docs's
changelog.
10.5.8
10.5.7
10.5.6
- Dependencies: Pin
@testing-library/jest-dom to
6.9.1 - #35614,
thanks @ndelangen!
- ESLint Plugin: Add plugin meta and document oxlint usage - #35655,
thanks
@yannbf!
- Vue: Skip docgen for module ids carrying a query - #35598,
thanks
@seanogdev!
10.5.5
10.5.4
10.5.3
10.5.2
- TanStack: Fix createServerFn validator mock - #35185,
thanks
@sjh9714!
- TanStack: Support pathless layout routes (id-only) in story routing
- #35465,
thanks
@unpunnyfuns!
- Tanstack-react: Add missing Hydrate export - #35111,
thanks
@arun-357!
- Tanstack-react: Keep JSX-only component references during dead-code
elimination - #35206,
thanks
@yatishgoel!
- Vitest: Fix coverage toggle crash on Vite 6 by clearing closed
Vitest instance on restart - #35461,
thanks
@yannbf!
10.5.1
Commits
6ef7d1a
Bump version from "10.5.7" to "10.5.8" [skip
ci]
7c6fb3a
Bump version from "10.5.6" to "10.5.7" [skip
ci]
3126f0a
Bump version from "10.5.5" to "10.5.6" [skip
ci]
05a52b7
Bump version from "10.5.4" to "10.5.5" [skip
ci]
3327dc4
Bump version from "10.5.3" to "10.5.4" [skip
ci]
9ac2739
Bump version from "10.5.2" to "10.5.3" [skip
ci]
b4b00f2
Merge pull request #34971
from storybookjs/valentin/upgrade-typescript-6
518f711
Bump version from "10.5.1" to "10.5.2" [skip
ci]
c253a06
Bump version from "10.5.0" to "10.5.1" [skip
ci]
- See full diff in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
pnpm-lock.yaml | 75 +++++++++++++++++++++++++++++++++++++++++++------
ui/package.json | 2 +-
2 files changed, 67 insertions(+), 10 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d11ed347cc..785ce6bfd89 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1016,8 +1016,8 @@ importers:
specifier: 10.5.4
version: 10.5.4(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))
'@storybook/addon-docs':
- specifier: 10.5.0
- version: 10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
+ specifier: 10.5.8
+ version: 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
'@storybook/react-vite':
specifier: 10.5.0
version: 10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
@@ -4742,11 +4742,11 @@ packages:
peerDependencies:
storybook: ^10.5.4
- '@storybook/addon-docs@10.5.0':
- resolution: {integrity: sha512-kbZGdX+mysiY4xezhpcFEwzQ1zSXcMhSS3u09Qt8+w5XetCAMMSv/yAxzpi6z+YoH+EdQ53e1q3MFtPUkzkfUA==}
+ '@storybook/addon-docs@10.5.8':
+ resolution: {integrity: sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- storybook: ^10.5.0
+ storybook: ^10.5.8
peerDependenciesMeta:
'@types/react':
optional: true
@@ -4775,6 +4775,24 @@ packages:
webpack:
optional: true
+ '@storybook/csf-plugin@10.5.8':
+ resolution: {integrity: sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==}
+ peerDependencies:
+ esbuild: '*'
+ rollup: '>=4.59.0'
+ storybook: ^10.5.8
+ vite: '*'
+ webpack: '*'
+ peerDependenciesMeta:
+ esbuild:
+ optional: true
+ rollup:
+ optional: true
+ vite:
+ optional: true
+ webpack:
+ optional: true
+
'@storybook/global@5.0.0':
resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
@@ -4797,6 +4815,20 @@ packages:
'@types/react-dom':
optional: true
+ '@storybook/react-dom-shim@10.5.8':
+ resolution: {integrity: sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^19.2.7
+ react-dom: ^19.2.7
+ storybook: ^10.5.8
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@storybook/react-vite@10.5.0':
resolution: {integrity: sha512-d9n/I3pViscXpJYT9JHxcpxVSam14HsHOr2wBqTQTiRtaiH4xSsT00HTEGm6CEZTyq/npTJyV0Qday9XhrtiDQ==}
peerDependencies:
@@ -5255,6 +5287,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.18.0:
+ resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
acpx@0.12.0:
resolution: {integrity: sha512-APYpN04XFWrCGuSBvM4HTKWWFH8uSIuzc+qI7aCGeVdP9o4euZeBosFEkmNUHvBOop0XBemg6d8RsNvzXN3Mgw==}
engines: {node: '>=22.13.0'}
@@ -12399,12 +12436,12 @@ snapshots:
axe-core: 4.12.1
storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7)
- '@storybook/addon-docs@10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))':
+ '@storybook/addon-docs@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7)
- '@storybook/csf-plugin': 10.5.0(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
+ '@storybook/csf-plugin': 10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
'@storybook/icons': 2.1.0(react@19.2.7)
- '@storybook/react-dom-shim': 10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))
+ '@storybook/react-dom-shim': 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7)
@@ -12438,6 +12475,15 @@ snapshots:
rollup: 4.62.4
vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)
+ '@storybook/csf-plugin@10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))':
+ dependencies:
+ storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7)
+ unplugin: 2.3.11
+ optionalDependencies:
+ esbuild: 0.28.1
+ rollup: 4.62.4
+ vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)
+
'@storybook/global@5.0.0': {}
'@storybook/icons@2.1.0(react@19.2.7)':
@@ -12453,6 +12499,15 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
+ '@storybook/react-dom-shim@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))':
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
'@storybook/react-vite@10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
@@ -13000,6 +13055,8 @@ snapshots:
acorn@8.17.0: {}
+ acorn@8.18.0: {}
+
acpx@0.12.0(patch_hash=x3fethhotv43zektyl5prdwf54):
dependencies:
'@agentclientprotocol/sdk': 1.2.1(zod@4.4.3)
@@ -16776,7 +16833,7 @@ snapshots:
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
- acorn: 8.17.0
+ acorn: 8.18.0
picomatch: 4.0.5
webpack-virtual-modules: 0.6.2
diff --git a/ui/package.json b/ui/package.json
index 1e81e21011c..83e3816b936 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -71,7 +71,7 @@
},
"devDependencies": {
"@storybook/addon-a11y": "10.5.4",
- "@storybook/addon-docs": "10.5.0",
+ "@storybook/addon-docs": "10.5.8",
"@storybook/react-vite": "10.5.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^22.20.1",
From d1cd9c37f49e21e0f248918bce24cff137e3802d Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 18 Aug 2026 15:06:24 -0500
Subject: [PATCH 09/39] fix(ui): keep Archive available on every inbox item
(#11636)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Mine inbox combines tasks, failed runs, approvals, and access
requests
> - Each item needs a consistent action that removes it from the inbox
> - Task rows keep the Archive action separate from the unread marker
> - Other rows used one slot for both actions, so an unread marker hid
Archive
> - This pull request gives every Mine inbox row the same Archive action
> - The benefit is consistent hover and swipe cleanup for every inbox
item type
## Linked Issues or Issue Description
**What happened?**
Unread failed runs, approvals, and join requests showed a blue unread
marker but no Archive button. The user had to mark the item as read
before the old leading Archive control became available.
**Expected behavior**
Every Mine inbox item shows Archive on hover. Every item also keeps its
swipe-to-archive behavior.
**Steps to reproduce**
1. Open the Mine inbox.
2. Add an unread failed run, approval, or join request.
3. Hover the new item.
4. Observe that Archive is missing before this change.
**Paperclip version or commit**
Current `master` at `0e9b03832d`.
**Deployment mode**
Local development from source.
**Additional context**
PR #1860 improves the accessibility of the existing swipe gesture. PR
#9704 changes server-side archive resurfacing rules. Neither PR adds the
missing hover action to unread non-task rows.
## What Changed
- Reused the task-row Archive action for failed runs, approvals, and
join requests.
- Kept the unread marker visible in its own leading slot.
- Kept retry, approve, and reject actions beside the new trailing
Archive action.
- Added regression coverage for hover Archive and swipe wrappers on
every non-task Mine row type.
- Updated a test comment so the repository token gate does not treat
documented color literals as UI source.
## Verification
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Inbox.test.tsx src/components/IssueRow.test.tsx
src/components/SwipeToArchive.test.tsx` — 46 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — passed.
- `pnpm -r typecheck` — passed.
- `pnpm test:run` — 4,284 tests passed. Ten unrelated runtime-exposure
tests cannot claim fixed ports 42000 and 52000 because host Tailscale
listeners already own them.
- `pnpm build` — passed.
- GitHub CI on the latest head — all required checks passed.
- Greptile — 5/5 confidence with no open review threads.
## Risks
- Low risk. The change only affects Mine inbox row actions and
regression tests.
- The server archive and dismiss APIs do not change.
- Retry, approve, and reject behavior does not change.
- No documentation change is required because this fix restores the
expected inbox behavior.
> 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
- OpenAI Codex based on GPT-5. The runtime did not expose a dated model
ID or context-window size. Reasoning, terminal tool use, code execution,
and GitHub integration were enabled.
## 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/IssueRow.tsx | 54 +++---
.../MarkdownCodeBlockStyles.test.ts | 4 +-
ui/src/pages/Inbox.test.tsx | 158 +++++++++++++-----
ui/src/pages/Inbox.tsx | 81 ++++-----
4 files changed, 179 insertions(+), 118 deletions(-)
diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx
index bcede8f1ddc..6d1027ba9f5 100644
--- a/ui/src/components/IssueRow.tsx
+++ b/ui/src/components/IssueRow.tsx
@@ -61,6 +61,38 @@ interface IssueRowProps {
showDivider?: boolean;
}
+export function InboxArchiveButton({
+ onArchive,
+ disabled,
+}: {
+ onArchive: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+
export function IssueRow({
issue,
issueLinkState,
@@ -292,27 +324,7 @@ export function IssueRow({
{(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? (
{onArchive ? (
-
+
) : null}
{externalObjectSummary ? (
diff --git a/ui/src/components/MarkdownCodeBlockStyles.test.ts b/ui/src/components/MarkdownCodeBlockStyles.test.ts
index f269e90bf86..13f31b82e47 100644
--- a/ui/src/components/MarkdownCodeBlockStyles.test.ts
+++ b/ui/src/components/MarkdownCodeBlockStyles.test.ts
@@ -18,8 +18,8 @@ function cssBlock(selector: string): string {
return stylesheet.slice(bodyStart + 1, bodyEnd);
}
-/* The rendered code block used to be pinned to the Catppuccin literals
- #1e1e2e / #cdd6f4, in the normal AND the prose-invert variables. A code
+/* The rendered code block used to be pinned to fixed Catppuccin literals
+ in the normal AND the prose-invert variables. A code
block therefore stayed dark in light mode. These tests fail if any of
those surfaces is pinned to a literal again, rather than riding a token
that carries a `.dark` override. */
diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx
index 4e5cb53bb5f..b7445fa50e0 100644
--- a/ui/src/pages/Inbox.test.tsx
+++ b/ui/src/pages/Inbox.test.tsx
@@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { Issue } from "@paperclipai/shared";
+import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyJoinRequest } from "../api/access";
import {
@@ -266,6 +266,73 @@ function createJoinRequest(
};
}
+function createApproval(overrides: Partial = {}): Approval {
+ return {
+ id: "approval-1",
+ companyId: "company-1",
+ type: "hire_agent",
+ requestedByAgentId: null,
+ requestedByUserId: "local-board",
+ status: "pending",
+ payload: { name: "New teammate" },
+ decisionNote: null,
+ decidedByUserId: null,
+ decidedAt: null,
+ createdAt: new Date("2026-03-11T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-11T00:00:00.000Z"),
+ ...overrides,
+ };
+}
+
+function createFailedRun(overrides: Partial = {}): HeartbeatRun {
+ return {
+ id: "run-1",
+ companyId: "company-1",
+ agentId: "agent-1",
+ responsibleUserId: null,
+ invocationSource: "assignment",
+ triggerDetail: null,
+ status: "failed",
+ error: "boom",
+ wakeupRequestId: null,
+ exitCode: null,
+ signal: null,
+ usageJson: null,
+ resultJson: null,
+ sessionIdBefore: null,
+ sessionIdAfter: null,
+ logStore: null,
+ logRef: null,
+ logBytes: null,
+ logSha256: null,
+ logCompressed: false,
+ stdoutExcerpt: null,
+ stderrExcerpt: null,
+ errorCode: null,
+ externalRunId: null,
+ processPid: null,
+ processGroupId: null,
+ processStartedAt: null,
+ lastOutputAt: null,
+ lastOutputSeq: 0,
+ lastOutputStream: null,
+ lastOutputBytes: null,
+ retryOfRunId: null,
+ processLossRetryCount: 0,
+ livenessState: null,
+ livenessReason: null,
+ continuationAttempt: 0,
+ lastUsefulActionAt: null,
+ nextAction: null,
+ contextSnapshot: null,
+ startedAt: new Date("2026-03-11T00:00:00.000Z"),
+ finishedAt: new Date("2026-03-11T00:01:00.000Z"),
+ createdAt: new Date("2026-03-11T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-11T00:01:00.000Z"),
+ ...overrides,
+ };
+}
+
function resetInboxApiMocks() {
for (const mock of Object.values(apiMocks)) mock.mockReset();
externalObjectMocks.summaries.clear();
@@ -345,6 +412,49 @@ describe("Inbox toolbar", () => {
act(() => root.unmount());
});
+ it("keeps archive hover actions and swipe targets on every unread non-task Mine row", async () => {
+ routerMock.location.pathname = "/inbox/mine";
+ localStorage.setItem("paperclip:inbox:group-by", "none");
+ apiMocks.approvalsList.mockResolvedValue([createApproval()]);
+ apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]);
+ apiMocks.joinRequestsList.mockResolvedValue([createJoinRequest()]);
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
+ });
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await vi.waitFor(() => {
+ expect(container.textContent).toContain("Hire Agent: New teammate");
+ expect(container.textContent).toContain("Failed run");
+ expect(container.textContent).toContain("Jordan Example");
+ });
+
+ const rowFor = (text: string) =>
+ [...container.querySelectorAll("[data-inbox-item]")]
+ .find((row) => row.textContent?.includes(text));
+
+ for (const text of ["Hire Agent: New teammate", "Failed run", "Jordan Example"]) {
+ const row = rowFor(text);
+ expect(row, `missing inbox row for ${text}`).toBeDefined();
+ expect(row?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull();
+ const archiveButton = row?.querySelector('button[aria-label="Archive"]');
+ expect(archiveButton).not.toBeNull();
+ expect(archiveButton?.className).toContain("opacity-0");
+ expect(archiveButton?.className).toContain("group-hover:opacity-100");
+ expect(row?.querySelector("[data-inbox-row-surface]")).not.toBeNull();
+ }
+
+ act(() => root.unmount());
+ });
+
it("restores folded and unfolded sub-tasks across remounts", async () => {
routerMock.location.pathname = "/inbox/mine";
const storageKey = "paperclip:inbox:collapsed-parents:company-1";
@@ -990,51 +1100,7 @@ describe("FailedRunInboxRow", () => {
it("suppresses accent hover styling when selected", () => {
const root = createRoot(container);
- const run = {
- id: "run-1",
- companyId: "company-1",
- agentId: "agent-1",
- responsibleUserId: null,
- invocationSource: "assignment",
- triggerDetail: null,
- status: "failed",
- error: "boom",
- wakeupRequestId: null,
- exitCode: null,
- signal: null,
- usageJson: null,
- resultJson: null,
- sessionIdBefore: null,
- sessionIdAfter: null,
- logStore: null,
- logRef: null,
- logBytes: null,
- logSha256: null,
- logCompressed: false,
- lastOutputAt: null,
- lastOutputSeq: 0,
- lastOutputStream: null,
- lastOutputBytes: null,
- errorCode: null,
- externalRunId: null,
- processPid: null,
- processGroupId: null,
- processStartedAt: null,
- retryOfRunId: null,
- processLossRetryCount: 0,
- livenessState: null,
- livenessReason: null,
- continuationAttempt: 0,
- lastUsefulActionAt: null,
- nextAction: null,
- stdoutExcerpt: null,
- stderrExcerpt: null,
- contextSnapshot: null,
- startedAt: new Date("2026-03-11T00:00:00.000Z"),
- finishedAt: null,
- createdAt: new Date("2026-03-11T00:00:00.000Z"),
- updatedAt: new Date("2026-03-11T00:00:00.000Z"),
- } as const;
+ const run = createFailedRun();
act(() => {
root.render(
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 4bf93eb0d49..1d2fa37c626 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -79,7 +79,7 @@ import {
issueTrailingColumns,
} from "../components/IssueColumns";
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
-import { IssueRow } from "../components/IssueRow";
+import { InboxArchiveButton, IssueRow } from "../components/IssueRow";
import { BlockedInboxView } from "../components/BlockedInboxView";
import { SwipeToArchive } from "../components/SwipeToArchive";
@@ -340,16 +340,6 @@ export function FailedRunInboxRow({
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
- ) : onArchive ? (
-
) : (
)}
@@ -389,6 +379,9 @@ export function FailedRunInboxRow({
+ {onArchive ? (
+
+ ) : null}
- ) : onArchive ? (
-
) : (
)}
@@ -534,25 +517,32 @@ function ApprovalInboxRow({
- {showResolutionButtons ? (
+ {(onArchive || showResolutionButtons) ? (
-
-
+ {onArchive ? (
+
+ ) : null}
+ {showResolutionButtons ? (
+ <>
+
+
+ >
+ ) : null}
) : null}
@@ -632,16 +622,6 @@ function JoinRequestInboxRow({
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
- ) : onArchive ? (
-
) : (
)}
@@ -664,6 +644,9 @@ function JoinRequestInboxRow({
+ {onArchive ? (
+
+ ) : null}