Refresh run config before reusing workspaces (#8797)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs are assembled by the heartbeat service from agent config,
project workspaces, environment config, secret bindings, skills, and
runtime session state.
> - The heartbeat service intentionally reuses adapter sessions,
execution workspaces, and sandbox leases when that preserves useful
state.
> - Reuse becomes incorrect when the effective next-run config changes
after a saved session, workspace, or lease was created.
> - Stale reuse can make a later run appear pinned to old agent,
environment, secret, instruction, or workspace settings.
> - This pull request records non-sensitive fingerprints for the
effective session, workspace, and lease config at run boundaries.
> - When those fingerprints drift, Paperclip refreshes persisted runtime
config or starts fresh execution instead of reusing stale state.
> - The benefit is predictable next-run config freshness without storing
raw secret values, full env maps, provider credentials, or private path
details.

## Linked Issues or Issue Description

- Refs #8058
- Related PRs checked during dedup search: #4968, #4155, #84, #8480.
These cover nearby workspace/session routing or model-config freshness
areas, but do not duplicate this effective run config fingerprinting
path.

## What Changed

- Added effective run config fingerprinting for session, workspace, and
lease reuse decisions, with canonicalization that ignores generated
runtime noise and redacts sensitive values.
- Updated heartbeat reuse logic to compare stored and next-run
fingerprints, reset stale saved sessions, refresh persisted workspace
config snapshots, replace stale reused workspaces when required, and
avoid stale sandbox lease reuse.
- Included plain environment value drift via value hashes, without
storing the raw env values.
- Root-bound instruction content hashing so legacy direct absolute
instruction paths are represented but not read for config fingerprints.
- Batched secret/version metadata lookups for environment lease
fingerprinting.
- Added workspace operation/run result freshness metadata so operators
can inspect non-sensitive decision categories.
- Surfaced config freshness labels and next-run copy in the UI and docs.
- Added focused coverage for fingerprint redaction, session reset
decisions, workspace refresh/replace behavior, environment lease drift,
and persisted workspace restoration.

## Verification

- `git diff --check`
- Sensitive-data scan before push:
- `git diff --unified=0 origin/master...HEAD | rg -n --pcre2
"(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|-----BEGIN
(RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|AKIA[0-9A-Z]{16})"`
- `git diff --unified=0 origin/master...HEAD | rg -n --pcre2
"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"`
- `pnpm exec vitest run
server/src/__tests__/effective-run-config-fingerprints.test.ts
server/src/__tests__/heartbeat-workspace-session.test.ts
server/src/__tests__/environment-runtime.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/db clean`
- `pnpm test:run`
- `pnpm build`
- UI screenshots from Cutter:
-
https://artifacts.cutter.sh/8797/run-2f4827c-2026-06-30T18-57-25/preview/change-01.png
-
https://artifacts.cutter.sh/8797/run-2f4827c-2026-06-30T18-57-25/preview/change-02.png
-
https://artifacts.cutter.sh/8797/run-2f4827c-2026-06-30T18-57-25/preview/change-03.png

## Risks

- Medium: overly broad fingerprints could start fresh sessions,
workspaces, or sandbox leases more often than necessary.
- Medium: missing a config category would allow stale reuse to persist
for that category.
- Medium: legacy direct absolute instruction paths are no longer
content-hashed unless they are paired with an absolute managed
instructions root.
- Low data risk: fingerprint metadata stores hashes and category names,
not raw secrets, raw env values, provider credentials, or private path
details.

> 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 GPT-5 via Codex CLI / Codex coding agent, tool-enabled with
shell, Git, GitHub CLI, local test execution, and code editing. The
exact deployed model variant and context window are not exposed by this
environment.

## 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 <noreply@paperclip.ing>
Co-authored-by: Cody <cody@paperclip.ing>
This commit is contained in:
Devin Foley 2026-06-30 14:50:25 -07:00 committed by GitHub
parent 5bd6c6ec3c
commit a8f0ebaa80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 3325 additions and 249 deletions

View File

@ -300,6 +300,8 @@ pnpm paperclipai agent instructions-file:put <agent-id> --path AGENTS.md --conte
pnpm paperclipai agent instructions-file:delete <agent-id> --path AGENTS.md
```
Agent config, instructions, skills, project env, environment, secret, and workspace edits affect the next run. Active runs finish with the config they started with. When a saved session, reused workspace, or sandbox lease no longer matches the effective next-run config, Paperclip may start fresh execution and records non-sensitive freshness categories in run result JSON and workspace operation logs.
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
- creates a new long-lived agent API key

View File

@ -277,6 +277,12 @@ If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs f
Local adapters require their corresponding CLI/session setup on the machine running Paperclip. External adapters are installed through the adapter/plugin flow and should not require hardcoded imports in `server/` or `ui/`.
## Config Freshness
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with.
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.

View File

@ -1,5 +1,6 @@
export type WorkspaceOperationPhase =
| "worktree_prepare"
| "workspace_config_freshness"
| "workspace_provision"
| "workspace_teardown"
| "worktree_cleanup"

View File

@ -0,0 +1,303 @@
import { describe, expect, it } from "vitest";
import {
canonicalizeEffectiveRunConfigCategory,
createEffectiveRunConfigFingerprints,
diffEffectiveRunConfigFingerprints,
} from "../services/effective-run-config-fingerprints.ts";
describe("effective run config fingerprints", () => {
it("emits versioned deterministic fingerprints with stable object ordering", () => {
const first = createEffectiveRunConfigFingerprints({
session: {
adapterType: "codex_local",
adapterConfig: {
env: {
ZETA: "raw-zeta",
ALPHA: "raw-alpha",
PAPERCLIP_RUN_ID: "run-one",
},
model: "gpt-5.2",
},
},
workspace: {
repoRef: "main",
repoUrl: "https://github.com/example/repo.git",
workspaceRuntime: {
services: [
{
env: { PORT: "3100", FEATURE_FLAG: "enabled" },
command: "pnpm dev",
},
],
},
},
lease: {
environmentId: "environment-1",
config: { beta: true, alpha: 1 },
},
});
const second = createEffectiveRunConfigFingerprints({
lease: {
config: { alpha: 1, beta: true },
environmentId: "environment-1",
},
workspace: {
workspaceRuntime: {
services: [
{
command: "pnpm dev",
env: { FEATURE_FLAG: "enabled", PORT: "3100" },
},
],
},
repoUrl: "https://github.com/example/repo.git",
repoRef: "main",
},
session: {
adapterConfig: {
model: "gpt-5.2",
env: {
PAPERCLIP_RUN_ID: "run-two",
ALPHA: "raw-alpha",
ZETA: "raw-zeta",
},
},
adapterType: "codex_local",
},
});
expect(first.version).toBe(1);
expect(first.categories).toEqual(["session", "workspace", "lease"]);
expect(first.sessionFingerprint).toMatchObject({
version: 1,
category: "session",
algorithm: "sha256",
});
expect(first.sessionFingerprint.fingerprint).toMatch(/^v1:sha256:[a-f0-9]{64}$/);
expect(second).toEqual(first);
});
it("reports changed categories independently", () => {
const base = createEffectiveRunConfigFingerprints({
session: { adapterType: "codex_local", model: "gpt-5.2" },
workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" } },
lease: { environmentId: "environment-1", reuseLease: true },
});
expect(diffEffectiveRunConfigFingerprints(
base,
createEffectiveRunConfigFingerprints({
session: { adapterType: "codex_local", model: "gpt-5.3" },
workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" } },
lease: { environmentId: "environment-1", reuseLease: true },
}),
)).toMatchObject({
hasChanges: true,
changedCategories: ["session"],
changed: { session: true, workspace: false, lease: false },
});
expect(diffEffectiveRunConfigFingerprints(
base,
createEffectiveRunConfigFingerprints({
session: { adapterType: "codex_local", model: "gpt-5.2" },
workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "custom-{{issue.identifier}}" } },
lease: { environmentId: "environment-1", reuseLease: true },
}),
).changedCategories).toEqual(["workspace"]);
expect(diffEffectiveRunConfigFingerprints(
base,
createEffectiveRunConfigFingerprints({
session: { adapterType: "codex_local", model: "gpt-5.2" },
workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" } },
lease: { environmentId: "environment-2", reuseLease: true },
}),
).changedCategories).toEqual(["lease"]);
});
it("uses resolved secret version metadata without raw secret values", () => {
const v7 = createEffectiveRunConfigFingerprints({
session: {
adapterConfig: {
env: {
OPENAI_API_KEY: "resolved-secret-value",
PLAIN_TEXT: "plain env value",
},
},
},
secretManifest: [
{
configPath: "env.OPENAI_API_KEY",
envKey: "OPENAI_API_KEY",
secretId: "secret-1",
bindingId: "binding-1",
version: 7,
provider: "local_encrypted",
providerVersionRef: "provider-version-7",
outcome: "success",
},
],
});
const canonical = v7.sessionFingerprint.canonicalJson;
expect(canonical).toContain("secret-1");
expect(canonical).toContain("binding-1");
expect(canonical).toContain("provider-version-7");
expect(canonical).toContain("\"version\":7");
expect(canonical).not.toContain("resolved-secret-value");
expect(canonical).not.toContain("plain env value");
expect(canonical).not.toContain("fingerprintSha256");
const rawValueChanged = createEffectiveRunConfigFingerprints({
session: {
adapterConfig: {
env: {
OPENAI_API_KEY: "different-resolved-secret-value",
PLAIN_TEXT: "plain env value",
},
},
},
secretManifest: [
{
configPath: "env.OPENAI_API_KEY",
envKey: "OPENAI_API_KEY",
secretId: "secret-1",
bindingId: "binding-1",
version: 7,
provider: "local_encrypted",
providerVersionRef: "provider-version-7",
outcome: "success",
},
],
});
expect(rawValueChanged.sessionFingerprint.fingerprint).toBe(v7.sessionFingerprint.fingerprint);
const versionChanged = createEffectiveRunConfigFingerprints({
session: {
adapterConfig: {
env: {
OPENAI_API_KEY: "different-resolved-secret-value",
PLAIN_TEXT: "plain env value",
},
},
},
secretManifest: [
{
configPath: "env.OPENAI_API_KEY",
envKey: "OPENAI_API_KEY",
secretId: "secret-1",
bindingId: "binding-1",
version: 8,
provider: "local_encrypted",
providerVersionRef: "provider-version-8",
outcome: "success",
},
],
});
expect(versionChanged.sessionFingerprint.fingerprint).not.toBe(v7.sessionFingerprint.fingerprint);
});
it("detects plain env value drift without storing raw values", () => {
const base = createEffectiveRunConfigFingerprints({
session: {
adapterConfig: {
env: {
FEATURE_FLAG: "enabled",
},
},
},
});
const changed = createEffectiveRunConfigFingerprints({
session: {
adapterConfig: {
env: {
FEATURE_FLAG: "disabled",
},
},
},
});
expect(changed.sessionFingerprint.fingerprint).not.toBe(base.sessionFingerprint.fingerprint);
expect(base.sessionFingerprint.canonicalJson).toContain('"valueHash":"sha256:');
expect(base.sessionFingerprint.canonicalJson).not.toContain("enabled");
expect(changed.sessionFingerprint.canonicalJson).not.toContain("disabled");
});
it("excludes generated run values, sensitive tokens, timestamps, and session path noise", () => {
const first = createEffectiveRunConfigFingerprints({
session: {
sessionId: "generated-session-one",
runId: "run-one",
cwd: "/runtime/noise/project-a",
updatedAt: "2026-06-01T00:00:00.000Z",
adapterConfig: {
token: "token-one",
nested: {
authorization: "Bearer first",
},
env: {
PAPERCLIP_API_KEY: "runtime-api-key-one",
NORMAL_VALUE: "first",
},
},
},
lease: {
leaseId: "lease-one",
providerLeaseId: "provider-lease-one",
remoteCwd: "/runtime/noise/remote-a",
createdAt: "2026-06-01T00:00:00.000Z",
driver: "daytona",
},
});
const second = createEffectiveRunConfigFingerprints({
session: {
sessionId: "generated-session-two",
runId: "run-two",
cwd: "/runtime/noise/project-b",
updatedAt: "2026-06-02T00:00:00.000Z",
adapterConfig: {
token: "token-two",
nested: {
authorization: "Bearer second",
},
env: {
PAPERCLIP_API_KEY: "runtime-api-key-two",
NORMAL_VALUE: "first",
},
},
},
lease: {
leaseId: "lease-two",
providerLeaseId: "provider-lease-two",
remoteCwd: "/runtime/noise/remote-b",
createdAt: "2026-06-02T00:00:00.000Z",
driver: "daytona",
},
});
expect(second.sessionFingerprint.fingerprint).toBe(first.sessionFingerprint.fingerprint);
expect(second.leaseFingerprint.fingerprint).toBe(first.leaseFingerprint.fingerprint);
const canonical = [
first.sessionFingerprint.canonicalJson,
first.leaseFingerprint.canonicalJson,
].join("\n");
expect(canonical).not.toContain("generated-session-one");
expect(canonical).not.toContain("run-one");
expect(canonical).not.toContain("/runtime/noise/project-a");
expect(canonical).not.toContain("runtime-api-key-one");
expect(canonical).not.toContain("token-one");
expect(canonical).not.toContain("Bearer first");
expect(canonical).not.toContain("lease-one");
expect(canonical).not.toContain("/runtime/noise/remote-a");
expect(canonicalizeEffectiveRunConfigCategory({
category: "workspace",
value: { cwd: "/explicit/runtime/workspace", workspaceStrategy: { type: "git_worktree" } },
})).toEqual({
cwd: "/explicit/runtime/workspace",
workspaceStrategy: { type: "git_worktree" },
});
});
});

View File

@ -392,7 +392,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
},
});
return { pluginId, companyId, executionWorkspaceId, reusableLease };
return { pluginId, companyId, agentId, environment, runId, executionWorkspaceId, reusableLease };
}
it("acquires and releases a local run lease through the runtime seam", async () => {
@ -1217,6 +1217,413 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
});
});
it("does not resume released reusable plugin sandbox leases after provider config drift", async () => {
const pluginId = randomUUID();
const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment();
const providerConfig = {
provider: "fake-plugin",
image: "template-a",
timeoutMs: 1234,
reuseLease: true,
};
const environment = {
...baseEnvironment,
name: "Reusable Plugin Sandbox",
driver: "sandbox",
config: providerConfig,
};
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,
config: providerConfig,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.fake-sandbox-provider",
packageName: "@acme/fake-sandbox-provider",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.fake-sandbox-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Fake Sandbox Provider",
description: "Test schema-driven provider",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake Plugin",
supportsReusableLeases: true,
configSchema: {
type: "object",
properties: {
image: { type: "string" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
const executionWorkspaceId = randomUUID();
const projectId = randomUUID();
await db.insert(projects).values({
id: projectId,
companyId,
name: `Workspace ${projectId.slice(0, 8)}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Reusable workspace",
status: "active",
providerType: "local_fs",
createdAt: new Date(),
updatedAt: new Date(),
});
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string, params: any) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: `lease-${params.config.image}`,
metadata: {
provider: "fake-plugin",
image: params.config.image,
timeoutMs: params.config.timeoutMs,
reuseLease: true,
remoteCwd: "/workspace",
},
};
}
if (method === "environmentReleaseLease" || method === "environmentDestroyLease") {
return undefined;
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const first = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: {
id: executionWorkspaceId,
mode: "shared_workspace",
},
});
expect(first.lease.metadata?.reusableSandboxLease).toMatchObject({
provider: "fake-plugin",
leaseFingerprint: expect.objectContaining({
category: "lease",
fingerprint: expect.stringMatching(/^v1:sha256:[a-f0-9]{64}$/),
}),
});
await runtimeWithPlugin.releaseRunLeases(runId);
const nextRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: nextRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: new Date(),
updatedAt: new Date(),
});
const updatedEnvironment = {
...environment,
config: {
...providerConfig,
image: "template-b",
},
};
await environmentService(db).update(environment.id, {
config: updatedEnvironment.config,
});
const second = await runtimeWithPlugin.acquireRunLease({
companyId,
environment: updatedEnvironment,
issueId: null,
agentId,
heartbeatRunId: nextRunId,
persistedExecutionWorkspace: {
id: executionWorkspaceId,
mode: "shared_workspace",
},
});
expect(second.lease.providerLeaseId).toBe("lease-template-b");
expect(workerManager.call).not.toHaveBeenCalledWith(
pluginId,
"environmentResumeLease",
expect.anything(),
expect.anything(),
);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", expect.objectContaining({
providerLeaseId: "lease-template-a",
}), 31234);
await expect(environmentService(db).getLeaseById(first.lease.id)).resolves.toMatchObject({
status: "expired",
cleanupStatus: "success",
failureReason: "lease_fingerprint_mismatch",
});
});
it("does not resume released reusable plugin sandbox leases after secret version drift", async () => {
const pluginId = randomUUID();
const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment();
const apiSecret = await secretService(db).create(companyId, {
name: `secure-plugin-api-key-${randomUUID()}`,
provider: "local_encrypted",
value: "resolved-provider-key",
});
const providerConfig = {
provider: "secure-plugin",
template: "base",
apiKey: apiSecret.id,
timeoutMs: 1234,
reuseLease: true,
};
const environment = {
...baseEnvironment,
name: "Secure Plugin Sandbox",
driver: "sandbox",
config: providerConfig,
};
await secretService(db).createBinding({
companyId,
secretId: apiSecret.id,
targetType: "environment",
targetId: environment.id,
configPath: "apiKey",
});
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,
config: providerConfig,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.secure-sandbox-provider",
packageName: "@acme/secure-sandbox-provider",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.secure-sandbox-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Secure Sandbox Provider",
description: "Test schema-driven provider",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
supportsReusableLeases: true,
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
const executionWorkspaceId = randomUUID();
const projectId = randomUUID();
await db.insert(projects).values({
id: projectId,
companyId,
name: `Workspace ${projectId.slice(0, 8)}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Reusable workspace",
status: "active",
providerType: "local_fs",
createdAt: new Date(),
updatedAt: new Date(),
});
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string, params: any) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: `lease-${params.config.apiKey}`,
metadata: {
provider: "secure-plugin",
template: params.config.template,
apiKey: params.config.apiKey,
timeoutMs: params.config.timeoutMs,
reuseLease: true,
remoteCwd: "/workspace",
},
};
}
if (method === "environmentReleaseLease" || method === "environmentDestroyLease") {
return undefined;
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const first = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: {
id: executionWorkspaceId,
mode: "shared_workspace",
},
});
await runtimeWithPlugin.releaseRunLeases(runId);
await secretService(db).rotate(apiSecret.id, { value: "rotated-provider-key" });
const nextRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: nextRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: new Date(),
updatedAt: new Date(),
});
const second = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: nextRunId,
persistedExecutionWorkspace: {
id: executionWorkspaceId,
mode: "shared_workspace",
},
});
expect(second.lease.providerLeaseId).toBe("lease-rotated-provider-key");
expect(workerManager.call).not.toHaveBeenCalledWith(
pluginId,
"environmentResumeLease",
expect.anything(),
expect.anything(),
);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", expect.objectContaining({
providerLeaseId: "lease-resolved-provider-key",
}), 31234);
await expect(environmentService(db).getLeaseById(first.lease.id)).resolves.toMatchObject({
status: "expired",
cleanupStatus: "success",
failureReason: "lease_fingerprint_mismatch",
});
const firstMetadata = JSON.stringify(first.lease.metadata);
expect(firstMetadata).not.toContain("resolved-provider-key");
expect(firstMetadata).not.toContain("rotated-provider-key");
});
it("preserves active reusable sandbox leases held by another running run", async () => {
const { pluginId, companyId, agentId, environment, executionWorkspaceId, reusableLease } =
await seedReusablePluginSandboxLease();
const nextRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: nextRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: new Date(),
updatedAt: new Date(),
});
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "fresh-plugin-lease",
metadata: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1234,
reuseLease: true,
remoteCwd: "/workspace",
},
};
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const acquired = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: nextRunId,
persistedExecutionWorkspace: {
id: executionWorkspaceId,
mode: "shared_workspace",
},
});
expect(acquired.lease.providerLeaseId).toBe("fresh-plugin-lease");
expect(workerManager.call).toHaveBeenCalledOnce();
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({
agentId,
executionWorkspaceId,
runId: nextRunId,
}), 31234);
await expect(environmentService(db).getLeaseById(reusableLease.id)).resolves.toMatchObject({
status: "active",
cleanupStatus: null,
});
});
it("does not retain or resume plugin-backed sandbox leases unless the provider opts in", async () => {
const pluginId = randomUUID();
const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment();

View File

@ -11,8 +11,10 @@ import {
applyPersistedExecutionWorkspaceConfig,
assertGitSensitiveAdapterWorkspaceValid,
assertPushCapabilityCheckoutValid,
buildRealizedExecutionWorkspaceFromPersisted,
buildExplicitResumeSessionOverride,
buildEffectiveRunSessionConfigMetadata,
buildEffectiveRunWorkspaceConfigMetadata,
buildWorkspaceConfigFreshnessOperation,
deriveTaskKeyWithHeartbeatFallback,
extractWakeCommentIds,
formatRuntimeWorkspaceWarningLog,
@ -21,7 +23,9 @@ import {
preflightLowTrustWorkspaceIsolation,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
resolveExecutionWorkspaceConfigFreshness,
resolveNextSessionState,
resolveTaskSessionConfigFreshness,
requiresPushCapabilityPreflight,
resolveWorkspaceAfterLowTrustPreflight,
resolveRuntimeSessionParamsForWorkspace,
@ -30,6 +34,7 @@ import {
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForModelChange,
stripConfiguredModelFromSessionParams,
stripPaperclipSessionMetadataFromSessionParams,
normalizeSessionParams,
shouldResetTaskSessionForWake,
type ResolvedWorkspaceForRun,
@ -812,48 +817,266 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
});
});
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
it("reuses the persisted execution workspace path instead of deriving a new worktree", () => {
const result = buildRealizedExecutionWorkspaceFromPersisted({
base: buildResolvedWorkspace({
cwd: "/tmp/project-primary",
repoRef: "main",
}),
workspace: {
id: "execution-workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-880-thumbs-capture-for-evals-feature",
status: "active",
cwd: "/tmp/reused-worktree",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "PAP-880-thumbs-capture-for-evals-feature",
providerType: "git_worktree",
providerRef: "/tmp/reused-worktree",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
type WorkspaceConfigMetadata = ReturnType<typeof buildEffectiveRunWorkspaceConfigMetadata>;
function buildWorkspaceConfigMetadata(
overrides: Partial<Parameters<typeof buildEffectiveRunWorkspaceConfigMetadata>[0]> = {},
) {
return buildEffectiveRunWorkspaceConfigMetadata({
mode: "isolated_workspace",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
strategyType: "git_worktree",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/worktrees",
},
repoUrl: "https://github.com/example/repo.git",
repoRef: "origin/main",
configSnapshot: {
provisionCommand: "pnpm install",
teardownCommand: "pnpm stop",
cleanupCommand: "pnpm clean",
desiredState: "running",
serviceStates: { "0": "running" },
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
},
},
environment: {
selectedEnvironmentId: "environment-1",
driver: "local",
config: { provider: "local" },
},
realization: {
environmentDriver: "local",
environmentProvider: "local",
},
evaluatedAt: "2026-06-26T00:00:00.000Z",
...overrides,
});
}
function persistedWorkspaceConfigFingerprint(metadata: WorkspaceConfigMetadata) {
return {
configFingerprint: {
version: metadata.version,
workspaceHash: metadata.fingerprint,
categories: metadata.categories,
categoryFingerprints: metadata.categoryFingerprints,
lastEvaluatedAt: metadata.evaluatedAt,
},
};
}
describe("effective run execution workspace config freshness", () => {
it("reuses an existing workspace when the stored workspace fingerprint is unchanged", () => {
const metadata = buildWorkspaceConfigMetadata();
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
nextMetadata: metadata,
});
expect(decision).toMatchObject({
action: "reuse",
shouldReuseExisting: true,
shouldRefreshConfigSnapshot: false,
changedCategories: [],
storedFingerprintPresent: true,
});
});
it("refreshes metadata and config for runtime-service-only drift without replacing the workspace", () => {
const base = buildWorkspaceConfigMetadata();
const next = buildWorkspaceConfigMetadata({
configSnapshot: {
provisionCommand: "pnpm install",
teardownCommand: "pnpm stop",
cleanupCommand: "pnpm clean",
desiredState: "running",
serviceStates: { "0": "running" },
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
},
},
});
expect(result.created).toBe(false);
expect(result.strategy).toBe("git_worktree");
expect(result.cwd).toBe("/tmp/reused-worktree");
expect(result.worktreePath).toBe("/tmp/reused-worktree");
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
expect(result.source).toBe("task_session");
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
nextMetadata: next,
});
expect(decision).toMatchObject({
action: "refresh",
shouldReuseExisting: true,
shouldRefreshConfigSnapshot: true,
changedCategories: ["runtimeServices"],
});
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
existingMetadata: {
config: {
workspaceRuntime: { services: [{ name: "web", command: "pnpm dev", port: 3100 }] },
},
...persistedWorkspaceConfigFingerprint(base),
},
source: "task_session",
createdByRuntime: false,
configSnapshot: {
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
},
desiredState: "running",
serviceStates: { "0": "running" },
},
shouldReuseExisting: true,
shouldRefreshConfigSnapshot: true,
workspaceConfigMetadata: next,
baseRef: "origin/main",
baseRefSha: "abc123",
});
expect(metadata?.config).toMatchObject({
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
},
});
expect(metadata?.configFingerprint).toMatchObject({
workspaceHash: next.fingerprint,
categories: next.categories,
});
});
it.each([
{
name: "mode",
category: "mode",
next: buildWorkspaceConfigMetadata({ mode: "shared_workspace" }),
},
{
name: "workspace strategy",
category: "strategy",
next: buildWorkspaceConfigMetadata({
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
branchTemplate: "custom-{{issue.identifier}}",
worktreeParentDir: ".paperclip/worktrees",
},
}),
},
{
name: "project workspace",
category: "projectWorkspace",
next: buildWorkspaceConfigMetadata({ projectWorkspaceId: "workspace-2" }),
},
{
name: "base ref",
category: "repo",
next: buildWorkspaceConfigMetadata({
repoRef: "origin/release",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/release",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/worktrees",
},
}),
},
{
name: "environment realization",
category: "environment",
next: buildWorkspaceConfigMetadata({
environment: {
selectedEnvironmentId: "environment-2",
driver: "sandbox",
config: { provider: "daytona" },
},
realization: {
environmentDriver: "sandbox",
environmentProvider: "daytona",
},
}),
},
] as const)("replaces the workspace when $name changes", ({ category, next }) => {
const base = buildWorkspaceConfigMetadata();
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
nextMetadata: next,
});
expect(decision.action).toBe("replace");
expect(decision.shouldReuseExisting).toBe(false);
expect(decision.changedCategories).toContain(category);
});
it("formats a safe workspace operation payload for config drift decisions", () => {
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(buildWorkspaceConfigMetadata()),
nextMetadata: buildWorkspaceConfigMetadata({
configSnapshot: {
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
},
},
}),
});
const operation = buildWorkspaceConfigFreshnessOperation({
decision,
hasExistingWorkspace: true,
reuseRequested: true,
workspaceReused: true,
configSnapshotRefreshed: true,
previousWorkspaceId: "workspace-old",
activeWorkspaceId: "workspace-old",
});
expect(operation).toMatchObject({
metadata: {
kind: "config_freshness",
action: "refresh",
changedCategories: ["lifecycleCommands", "runtimeServices"],
changedCategoryLabels: ["workspace lifecycle commands", "runtime services"],
reuseRequested: true,
workspaceReused: true,
configSnapshotRefreshed: true,
previousWorkspaceId: "workspace-old",
activeWorkspaceId: "workspace-old",
},
system: expect.stringContaining("refreshed execution workspace config"),
});
const serialized = JSON.stringify(operation);
expect(serialized).toContain("runtime services");
expect(serialized).not.toContain("pnpm dev");
expect(serialized).not.toContain("0.0.0.0");
});
it("does not record a freshness operation when an unchanged workspace is simply reused", () => {
const metadata = buildWorkspaceConfigMetadata();
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
nextMetadata: metadata,
});
expect(buildWorkspaceConfigFreshnessOperation({
decision,
hasExistingWorkspace: true,
reuseRequested: true,
workspaceReused: true,
configSnapshotRefreshed: false,
previousWorkspaceId: "workspace-1",
activeWorkspaceId: "workspace-1",
})).toBeNull();
});
});
@ -1075,6 +1298,332 @@ describe("shouldResetTaskSessionForModelChange", () => {
});
});
type SessionConfigMetadata = Awaited<ReturnType<typeof buildEffectiveRunSessionConfigMetadata>>;
async function buildSessionConfigMetadata(
overrides: Partial<Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0]> = {},
) {
return buildEffectiveRunSessionConfigMetadata({
adapterType: "codex_local",
effectiveAdapterConfig: {
command: "codex",
model: "gpt-5.4-mini",
env: {
OPENAI_API_KEY: "resolved-secret-value",
PLAIN_FLAG: "plain-value",
},
},
agentRuntimeConfig: {
heartbeat: {
maxConcurrentRuns: 1,
},
},
modelProfile: null,
issueOverrides: null,
workspaceConfig: {
requestedMode: "agent_default",
effectiveMode: "agent_default",
projectConfigRevisionAt: "2026-06-01T00:00:00.000Z",
},
environment: {
selectionSource: "default",
selectedEnvironmentId: "environment-1",
selectedEnvironment: {
id: "environment-1",
driver: "local",
configRevisionAt: "2026-06-01T00:00:00.000Z",
},
},
environmentEnv: {
ENVIRONMENT_FLAG: "enabled",
},
projectEnv: {
PROJECT_FLAG: "enabled",
},
routineEnv: null,
secretManifest: [
{
configPath: "env.OPENAI_API_KEY",
envKey: "OPENAI_API_KEY",
secretId: "secret-1",
bindingId: "binding-1",
secretKey: "openai-api-key",
version: 7,
provider: "local_encrypted",
outcome: "success",
},
],
runtimeSkills: [
{
key: "paperclip",
runtimeName: "paperclip",
source: "/tmp/paperclip/runtime-skills/paperclip",
versionId: null,
currentVersionId: "skill-version-1",
sourceStatus: "available",
missingDetail: null,
},
],
agentConfigRevision: {
id: "agent-config-revision-1",
changedKeys: ["adapterConfig"],
configRevisionAt: "2026-06-01T00:00:00.000Z",
},
...overrides,
});
}
function sessionParamsWithConfigMetadata(
metadata: SessionConfigMetadata,
configuredModel = "gpt-5.4-mini",
) {
return {
sessionId: "thread-1",
__paperclipConfiguredModel: configuredModel,
__paperclipConfigFingerprint: metadata.fingerprint,
__paperclipConfigFingerprintVersion: metadata.version,
__paperclipConfigCategories: metadata.categories,
__paperclipConfigCategoryFingerprints: metadata.categoryFingerprints,
};
}
describe("effective run session config freshness", () => {
it("resets when effective adapter config changes after model/profile/env resolution", async () => {
const base = await buildSessionConfigMetadata();
const next = await buildSessionConfigMetadata({
effectiveAdapterConfig: {
command: "codex",
model: "gpt-5.4-mini",
approvalPolicy: "never",
},
});
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: sessionParamsWithConfigMetadata(base),
configMetadata: next,
});
expect(decision).toMatchObject({
reset: true,
changedCategories: ["adapterConfig"],
});
expect(decision.reasons.join("\n")).toContain("adapter config");
});
it("keeps model-only compatibility as an additional reset reason", async () => {
const base = await buildSessionConfigMetadata();
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: sessionParamsWithConfigMetadata(base, "opencode/mimo-v2-pro-free"),
configMetadata: base,
});
expect(decision.reset).toBe(true);
expect(decision.reasons).toEqual([
'configured model changed from "opencode/mimo-v2-pro-free" to "gpt-5.4-mini"',
]);
});
it("freshens legacy task sessions that lack versioned config metadata", async () => {
const metadata = await buildSessionConfigMetadata();
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: {
sessionId: "thread-1",
__paperclipConfiguredModel: "gpt-5.4-mini",
},
configMetadata: metadata,
});
expect(decision.reset).toBe(true);
expect(decision.changedCategories).toEqual(metadata.categories);
expect(decision.reasons).toEqual(["effective run configuration fingerprint metadata is missing"]);
});
it("preserves legacy metadata gaps only for active accepted-plan continuation sessions", async () => {
const metadata = await buildSessionConfigMetadata();
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: {
sessionId: "thread-1",
__paperclipConfiguredModel: "gpt-5.4-mini",
},
configMetadata: metadata,
preserveLegacySessionWithoutConfigMetadata: true,
});
expect(decision.reset).toBe(false);
expect(decision.changedCategories).toEqual([]);
expect(decision.reasons).toEqual([]);
});
it("names safe categories for model profile, issue override, env, secret, and runtime skill drift", async () => {
const base = await buildSessionConfigMetadata();
const cases: Array<{
name: string;
category: string;
metadata: SessionConfigMetadata;
}> = [
{
name: "model profile",
category: "modelProfile",
metadata: await buildSessionConfigMetadata({
modelProfile: {
requested: "cheap",
applied: true,
configSource: "agent_runtime",
},
}),
},
{
name: "issue overrides",
category: "issueOverrides",
metadata: await buildSessionConfigMetadata({
issueOverrides: {
adapterConfig: {
reasoningEffort: "high",
},
},
}),
},
{
name: "project env bindings",
category: "envBindings",
metadata: await buildSessionConfigMetadata({
projectEnv: {
PROJECT_FLAG: "enabled",
NEW_PROJECT_FLAG: "present",
},
}),
},
{
name: "secret version",
category: "secrets",
metadata: await buildSessionConfigMetadata({
secretManifest: [
{
configPath: "env.OPENAI_API_KEY",
envKey: "OPENAI_API_KEY",
secretId: "secret-1",
bindingId: "binding-1",
secretKey: "openai-api-key",
version: 8,
provider: "local_encrypted",
outcome: "success",
},
],
}),
},
{
name: "runtime skills",
category: "runtimeSkills",
metadata: await buildSessionConfigMetadata({
runtimeSkills: [
{
key: "paperclip",
runtimeName: "paperclip",
source: "/tmp/paperclip/runtime-skills/paperclip",
versionId: null,
currentVersionId: "skill-version-2",
sourceStatus: "available",
missingDetail: null,
},
],
}),
},
];
for (const testCase of cases) {
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: sessionParamsWithConfigMetadata(base),
configMetadata: testCase.metadata,
});
expect(decision.reset, testCase.name).toBe(true);
expect(decision.changedCategories, testCase.name).toContain(testCase.category);
}
});
it("detects instructions content drift without storing the contents", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-"));
const instructionsPath = path.join(root, "AGENTS.md");
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
const base = await buildSessionConfigMetadata({
effectiveAdapterConfig: {
command: "codex",
model: "gpt-5.4-mini",
instructionsBundleMode: "managed",
instructionsRootPath: root,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: instructionsPath,
},
});
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
const next = await buildSessionConfigMetadata({
effectiveAdapterConfig: {
command: "codex",
model: "gpt-5.4-mini",
instructionsBundleMode: "managed",
instructionsRootPath: root,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: instructionsPath,
},
});
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: sessionParamsWithConfigMetadata(base),
configMetadata: next,
});
expect(decision.reset).toBe(true);
expect(decision.changedCategories).toContain("instructions");
expect(next.fingerprints.sessionFingerprint.canonicalJson).not.toContain("Version two instructions");
});
it("does not read unbounded legacy instructions paths for config fingerprints", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-"));
const instructionsPath = path.join(root, "AGENTS.md");
await fs.writeFile(instructionsPath, "Legacy direct-path instructions.\n", "utf8");
const metadata = await buildSessionConfigMetadata({
effectiveAdapterConfig: {
command: "codex",
model: "gpt-5.4-mini",
instructionsFilePath: instructionsPath,
},
});
const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson;
expect(canonical).toContain("missing_absolute_root");
expect(canonical).not.toContain("Legacy direct-path instructions");
expect(canonical).not.toContain("contentHash");
});
it("does not include raw secret or plain env values in canonical session metadata", async () => {
const metadata = await buildSessionConfigMetadata();
const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson;
expect(canonical).toContain("secret-1");
expect(canonical).toContain('"version":7');
expect(canonical).not.toContain("resolved-secret-value");
expect(canonical).not.toContain("plain-value");
expect(canonical).not.toContain("enabled");
expect(canonical).not.toContain("openai-api-key");
});
});
describe("stripConfiguredModelFromSessionParams", () => {
it("removes the internal model key from persisted session params", () => {
expect(
@ -1108,6 +1657,25 @@ describe("stripConfiguredModelFromSessionParams", () => {
});
});
describe("stripPaperclipSessionMetadataFromSessionParams", () => {
it("removes all internal Paperclip session metadata before adapter invocation", () => {
expect(
stripPaperclipSessionMetadataFromSessionParams({
sessionId: "thread-1",
cwd: "/tmp/project",
__paperclipConfiguredModel: "gpt-5.4-mini",
__paperclipConfigFingerprint: "v1:sha256:abc",
__paperclipConfigFingerprintVersion: 1,
__paperclipConfigCategories: ["adapterConfig"],
__paperclipConfigCategoryFingerprints: { adapterConfig: "v1:sha256:def" },
}),
).toEqual({
sessionId: "thread-1",
cwd: "/tmp/project",
});
});
});
describe("normalizeSessionParams", () => {
it("collapses an empty object to null", () => {
expect(normalizeSessionParams({})).toBeNull();

View File

@ -2057,6 +2057,45 @@ describe("realizeExecutionWorkspace", () => {
expect(actualHead).toBe(expectedHead);
}, 15_000);
it("does not reuse a missing persisted local filesystem workspace", async () => {
const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-base-"));
const missingCwd = path.join(baseCwd, "missing-workspace");
const restored = await ensurePersistedExecutionWorkspaceAvailable({
base: {
baseCwd,
source: "project_primary",
projectId: "project-1",
workspaceId: null,
repoUrl: null,
repoRef: null,
},
workspace: {
mode: "shared_workspace",
strategyType: "project_primary",
cwd: missingCwd,
providerRef: null,
projectId: "project-1",
projectWorkspaceId: null,
repoUrl: null,
baseRef: null,
branchName: null,
},
issue: {
id: "issue-1",
identifier: "PAP-453",
title: "Missing local workspace",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(restored).toBeNull();
});
it("reprovisions an existing persisted git worktree before manual control starts it", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
@ -2195,6 +2234,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => {
const repoRoot = await createTempRepo("main");
await runGit(repoRoot, ["branch", "-f", "master", "main"]);
const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-"));
await runGit(bareRemote, ["init", "--bare"]);

View File

@ -0,0 +1,492 @@
import { createHash } from "node:crypto";
import type { RuntimeSecretManifestEntry } from "./secrets.js";
export const EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION = 1;
export const EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM = "sha256";
export const EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES = ["session", "workspace", "lease"] as const;
export type EffectiveRunConfigFingerprintCategory =
(typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES)[number];
export type EffectiveRunConfigChangedCategory = EffectiveRunConfigFingerprintCategory;
export type EffectiveRunConfigCanonicalValue =
| null
| boolean
| number
| string
| EffectiveRunConfigCanonicalValue[]
| { [key: string]: EffectiveRunConfigCanonicalValue };
export interface EffectiveRunConfigSecretVersionMetadata {
configPath: string;
envKey: string | null;
secretId: string;
bindingId?: string | null;
version: number | string;
provider?: string | null;
providerVersionRef?: string | null;
outcome?: "success" | "failure" | null;
}
export type EffectiveRunConfigSecretManifestEntry =
| RuntimeSecretManifestEntry
| EffectiveRunConfigSecretVersionMetadata;
export interface EffectiveRunConfigFingerprintInput {
session?: unknown;
workspace?: unknown;
lease?: unknown;
secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[];
}
export interface EffectiveRunConfigFingerprint {
version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION;
category: EffectiveRunConfigFingerprintCategory;
algorithm: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM;
fingerprint: string;
canonicalJson: string;
}
export interface EffectiveRunConfigFingerprints {
version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION;
categories: readonly EffectiveRunConfigFingerprintCategory[];
sessionFingerprint: EffectiveRunConfigFingerprint;
workspaceFingerprint: EffectiveRunConfigFingerprint;
leaseFingerprint: EffectiveRunConfigFingerprint;
}
export interface EffectiveRunConfigFingerprintDiff {
version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION;
hasChanges: boolean;
changedCategories: EffectiveRunConfigChangedCategory[];
changed: Record<EffectiveRunConfigChangedCategory, boolean>;
}
const OMIT = Symbol("omit-from-effective-run-config-fingerprint");
const REDACTED_VALUE: EffectiveRunConfigCanonicalValue = { type: "redacted", present: true };
const GENERATED_RUNTIME_ENV_KEY_RE = /^PAPERCLIP_/;
const SENSITIVE_CONFIG_KEY_RE =
/(?:api[_-]?key|access[_-]?token|auth(?:orization)?|bearer|cookie|credential|jwt|password|passwd|private[_-]?key|secret|token)$/i;
const VOLATILE_CONFIG_KEYS = new Set([
"checkoutRunId",
"executionRunId",
"externalRunId",
"heartbeatRunId",
"invocationId",
"leaseId",
"providerLeaseId",
"requestId",
"runId",
"sessionDisplayId",
"sessionId",
"spanId",
"traceId",
]);
const HOST_NOISE_KEYS = new Set([
"agentHome",
"homeDir",
"hostCwd",
"localHome",
"tempDir",
"tmpDir",
"userHome",
]);
const SESSION_HOST_PATH_KEYS = new Set([
"cwd",
"localPath",
"remoteCwd",
"workspaceCwd",
"workspacePath",
"workspaceRemoteDir",
"worktreePath",
]);
type SecretManifestIndex = {
byConfigPath: Map<string, EffectiveRunConfigSecretVersionMetadata>;
byEnvKey: Map<string, EffectiveRunConfigSecretVersionMetadata>;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value)
&& typeof value === "object"
&& !Array.isArray(value)
&& !(value instanceof Date);
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readVersion(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
return readString(value);
}
function normalizeSecretManifestEntry(
entry: EffectiveRunConfigSecretManifestEntry,
): EffectiveRunConfigSecretVersionMetadata | null {
const record = entry as Record<string, unknown>;
const secretId = readString(record.secretId);
const version = readVersion(record.version);
if (!secretId || version === null) return null;
const normalized: EffectiveRunConfigSecretVersionMetadata = {
configPath: readString(record.configPath) ?? "",
envKey: readString(record.envKey),
secretId,
version,
};
const bindingId = readString(record.bindingId);
const provider = readString(record.provider);
const providerVersionRef = readString(record.providerVersionRef);
const outcome = record.outcome === "success" || record.outcome === "failure"
? record.outcome
: null;
if (bindingId !== null) normalized.bindingId = bindingId;
if (provider !== null) normalized.provider = provider;
if (providerVersionRef !== null) normalized.providerVersionRef = providerVersionRef;
if (outcome !== null) normalized.outcome = outcome;
return normalized;
}
function buildSecretManifestIndex(
manifest: readonly EffectiveRunConfigSecretManifestEntry[] | undefined,
): SecretManifestIndex {
const byConfigPath = new Map<string, EffectiveRunConfigSecretVersionMetadata>();
const byEnvKey = new Map<string, EffectiveRunConfigSecretVersionMetadata>();
for (const entry of manifest ?? []) {
const normalized = normalizeSecretManifestEntry(entry);
if (!normalized) continue;
if (normalized.configPath) byConfigPath.set(normalized.configPath, normalized);
if (normalized.envKey) byEnvKey.set(normalized.envKey, normalized);
}
return { byConfigPath, byEnvKey };
}
function canonicalSecretMetadata(
metadata: EffectiveRunConfigSecretVersionMetadata,
): EffectiveRunConfigCanonicalValue {
return omitNullish({
type: "secret_ref",
configPath: metadata.configPath || undefined,
envKey: metadata.envKey ?? undefined,
secretId: metadata.secretId,
bindingId: metadata.bindingId ?? undefined,
version: metadata.version,
provider: metadata.provider ?? undefined,
providerVersionRef: metadata.providerVersionRef ?? undefined,
outcome: metadata.outcome ?? undefined,
});
}
function isSecretRefBinding(value: unknown): value is Record<string, unknown> {
return isPlainObject(value)
&& value.type === "secret_ref"
&& typeof value.secretId === "string"
&& value.secretId.trim().length > 0;
}
function canonicalSecretRefBinding(
value: Record<string, unknown>,
configPath: string,
): EffectiveRunConfigCanonicalValue {
return omitNullish({
type: "secret_ref",
configPath,
secretId: readString(value.secretId),
versionSelector: readVersion(value.version) ?? "latest",
unresolved: true,
});
}
function omitNullish(record: Record<string, unknown>): EffectiveRunConfigCanonicalValue {
return Object.fromEntries(
Object.entries(record).filter(([, value]) => value !== undefined && value !== null),
) as EffectiveRunConfigCanonicalValue;
}
function isTimestampNoiseKey(key: string) {
return /(created|updated|started|finished|completed|cancelled|resolved|used|heartbeat)At$/i.test(key)
&& !/(revision|version)/i.test(key);
}
function shouldOmitObjectKey(
category: EffectiveRunConfigFingerprintCategory,
key: string,
) {
if (VOLATILE_CONFIG_KEYS.has(key)) return true;
if (HOST_NOISE_KEYS.has(key)) return true;
if (isTimestampNoiseKey(key)) return true;
if (category === "session" && SESSION_HOST_PATH_KEYS.has(key)) return true;
if (category === "lease" && (key === "remoteCwd" || key === "workspaceRemoteDir")) return true;
return false;
}
function stableStringify(value: EffectiveRunConfigCanonicalValue): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, EffectiveRunConfigCanonicalValue>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key] ?? null)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function canonicalizePlainEnvValueForHash(value: unknown): EffectiveRunConfigCanonicalValue {
if (value === undefined || value === null) return null;
if (Array.isArray(value)) {
return value.map((entry) => canonicalizePlainEnvValueForHash(entry));
}
if (isPlainObject(value)) {
const out: Record<string, EffectiveRunConfigCanonicalValue> = {};
for (const key of Object.keys(value).sort()) {
const next = canonicalizePlainEnvValueForHash(value[key]);
if (next !== null) out[key] = next;
}
return out;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "bigint") {
return value.toString();
}
if (typeof value === "boolean" || typeof value === "string") {
return value;
}
return String(value);
}
function hashPlainEnvValue(value: unknown): string | null {
if (value === undefined || value === null) return null;
const canonicalJson = stableStringify(canonicalizePlainEnvValueForHash(value));
return `sha256:${createHash("sha256").update(canonicalJson).digest("hex")}`;
}
function canonicalizeEnvRecord(
envValue: unknown,
context: CanonicalizeContext,
): EffectiveRunConfigCanonicalValue | typeof OMIT {
if (!isPlainObject(envValue)) return {};
const canonicalEnv: Record<string, EffectiveRunConfigCanonicalValue> = {};
for (const key of Object.keys(envValue).sort()) {
if (GENERATED_RUNTIME_ENV_KEY_RE.test(key)) continue;
const manifestEntry = context.secrets.byConfigPath.get(`env.${key}`) ?? context.secrets.byEnvKey.get(key);
if (manifestEntry) {
canonicalEnv[key] = canonicalSecretMetadata(manifestEntry);
continue;
}
const rawBinding = envValue[key];
if (isSecretRefBinding(rawBinding)) {
canonicalEnv[key] = canonicalSecretRefBinding(rawBinding, `env.${key}`);
continue;
}
canonicalEnv[key] = omitNullish({
type: "plain_env",
present: rawBinding !== undefined && rawBinding !== null,
valueHash: hashPlainEnvValue(rawBinding),
});
}
return Object.keys(canonicalEnv).length > 0 ? canonicalEnv : OMIT;
}
type CanonicalizeContext = {
category: EffectiveRunConfigFingerprintCategory;
path: string[];
secrets: SecretManifestIndex;
};
function canonicalizeValue(
value: unknown,
context: CanonicalizeContext,
): EffectiveRunConfigCanonicalValue | typeof OMIT {
if (value === undefined || value instanceof Date) return OMIT;
if (value === null) return null;
if (Array.isArray(value)) {
return value.map((entry, index) => {
const next = canonicalizeValue(entry, { ...context, path: [...context.path, String(index)] });
return next === OMIT ? null : next;
});
}
if (isSecretRefBinding(value)) {
return canonicalSecretRefBinding(value, context.path.join("."));
}
if (isPlainObject(value)) {
const canonicalObject: Record<string, EffectiveRunConfigCanonicalValue> = {};
for (const key of Object.keys(value).sort()) {
if (key === "env") {
const env = canonicalizeEnvRecord(value[key], { ...context, path: [...context.path, key] });
if (env !== OMIT) canonicalObject[key] = env;
continue;
}
if (shouldOmitObjectKey(context.category, key)) continue;
if (SENSITIVE_CONFIG_KEY_RE.test(key)) {
canonicalObject[key] = REDACTED_VALUE;
continue;
}
const next = canonicalizeValue(value[key], { ...context, path: [...context.path, key] });
if (next !== OMIT) canonicalObject[key] = next;
}
return Object.keys(canonicalObject).length > 0 ? canonicalObject : {};
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "bigint") {
return value.toString();
}
if (typeof value === "boolean" || typeof value === "string") {
return value;
}
return null;
}
export function canonicalizeEffectiveRunConfigCategory(input: {
category: EffectiveRunConfigFingerprintCategory;
value: unknown;
secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[];
}): EffectiveRunConfigCanonicalValue {
const canonical = canonicalizeValue(input.value ?? {}, {
category: input.category,
path: [],
secrets: buildSecretManifestIndex(input.secretManifest),
});
return canonical === OMIT ? {} : canonical;
}
function createCategoryFingerprint(input: {
category: EffectiveRunConfigFingerprintCategory;
value: unknown;
secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[];
}): EffectiveRunConfigFingerprint {
const canonicalValue = canonicalizeEffectiveRunConfigCategory(input);
const canonicalJson = stableStringify({
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
category: input.category,
value: canonicalValue,
});
const digest = createHash("sha256").update(canonicalJson).digest("hex");
return {
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
category: input.category,
algorithm: EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM,
fingerprint: `v${EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION}:sha256:${digest}`,
canonicalJson,
};
}
function createCategoryFingerprintFromCanonicalValue(input: {
category: EffectiveRunConfigFingerprintCategory;
value: EffectiveRunConfigCanonicalValue;
}): EffectiveRunConfigFingerprint {
const canonicalJson = stableStringify({
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
category: input.category,
value: input.value,
});
const digest = createHash("sha256").update(canonicalJson).digest("hex");
return {
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
category: input.category,
algorithm: EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM,
fingerprint: `v${EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION}:sha256:${digest}`,
canonicalJson,
};
}
function canonicalRecord(value: EffectiveRunConfigCanonicalValue) {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, EffectiveRunConfigCanonicalValue>
: {};
}
export function createEffectiveRunConfigSubcategoryFingerprints<T extends string>(input: {
category: EffectiveRunConfigFingerprintCategory;
value: Record<T, unknown>;
subcategories: readonly T[];
secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[];
}): Record<T, string> {
const canonicalValue = canonicalizeEffectiveRunConfigCategory({
category: input.category,
value: input.value,
secretManifest: input.secretManifest,
});
const record = canonicalRecord(canonicalValue);
return Object.fromEntries(
input.subcategories.map((subcategory) => {
const value = Object.prototype.hasOwnProperty.call(record, subcategory)
? { [subcategory]: record[subcategory] ?? null }
: {};
return [
subcategory,
createCategoryFingerprintFromCanonicalValue({
category: input.category,
value,
}).fingerprint,
];
}),
) as Record<T, string>;
}
export function createEffectiveRunConfigFingerprints(
input: EffectiveRunConfigFingerprintInput,
): EffectiveRunConfigFingerprints {
return {
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
categories: EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES,
sessionFingerprint: createCategoryFingerprint({
category: "session",
value: input.session,
secretManifest: input.secretManifest,
}),
workspaceFingerprint: createCategoryFingerprint({
category: "workspace",
value: input.workspace,
secretManifest: input.secretManifest,
}),
leaseFingerprint: createCategoryFingerprint({
category: "lease",
value: input.lease,
secretManifest: input.secretManifest,
}),
};
}
function fingerprintForCategory(
fingerprints: EffectiveRunConfigFingerprints,
category: EffectiveRunConfigFingerprintCategory,
) {
switch (category) {
case "session":
return fingerprints.sessionFingerprint.fingerprint;
case "workspace":
return fingerprints.workspaceFingerprint.fingerprint;
case "lease":
return fingerprints.leaseFingerprint.fingerprint;
}
}
export function diffEffectiveRunConfigFingerprints(
previous: EffectiveRunConfigFingerprints,
next: EffectiveRunConfigFingerprints,
): EffectiveRunConfigFingerprintDiff {
const changed = Object.fromEntries(
EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES.map((category) => [
category,
fingerprintForCategory(previous, category) !== fingerprintForCategory(next, category),
]),
) as Record<EffectiveRunConfigChangedCategory, boolean>;
const changedCategories = EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES.filter((category) => changed[category]);
return {
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
hasChanges: changedCategories.length > 0,
changedCategories,
changed,
};
}

View File

@ -1,7 +1,7 @@
import { createHash, randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { environmentLeases } from "@paperclipai/db";
import { companySecrets, companySecretVersions, environmentLeases } from "@paperclipai/db";
import type {
Environment,
EnvironmentLease,
@ -18,10 +18,16 @@ import type {
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
import { environmentService } from "./environments.js";
import {
collectEnvironmentSecretRefs,
parseEnvironmentDriverConfig,
resolveEnvironmentDriverConfigForRuntime,
stripSandboxProviderEnvelope,
} from "./environment-config.js";
import {
createEffectiveRunConfigFingerprints,
type EffectiveRunConfigFingerprint,
type EffectiveRunConfigSecretVersionMetadata,
} from "./effective-run-config-fingerprints.js";
import {
acquireSandboxProviderLease,
destroySandboxProviderLease,
@ -255,6 +261,146 @@ function reusableRuntimeFingerprint(input: {
.digest("hex");
}
function serializeLeaseFingerprint(
fingerprint: EffectiveRunConfigFingerprint | null | undefined,
): Record<string, unknown> | null {
if (!fingerprint) return null;
return {
version: fingerprint.version,
category: fingerprint.category,
algorithm: fingerprint.algorithm,
fingerprint: fingerprint.fingerprint,
};
}
function readLeaseFingerprint(value: unknown): string | null {
return isRecord(value) ? readString(value.fingerprint) : null;
}
async function buildEnvironmentSecretMetadataForLeaseFingerprint(input: {
db: Db;
companyId: string;
environment: Environment;
}): Promise<EffectiveRunConfigSecretVersionMetadata[]> {
const refs = await collectEnvironmentSecretRefs({
db: input.db,
environment: input.environment,
});
if (refs.length === 0) return [];
const secretIds = [...new Set(refs.map((ref) => ref.secretId))];
const secretRows = await input.db
.select()
.from(companySecrets)
.where(inArray(companySecrets.id, secretIds));
const secretsById = new Map(
secretRows
.filter((secret) => secret.companyId === input.companyId)
.map((secret) => [secret.id, secret]),
);
const versionRequests = refs.flatMap((ref) => {
const secret = secretsById.get(ref.secretId);
if (!secret) return [];
const resolvedVersion = ref.versionSelector === "latest" || ref.versionSelector === undefined
? secret.latestVersion
: ref.versionSelector;
return typeof resolvedVersion === "number"
? [{ secretId: secret.id, version: resolvedVersion }]
: [];
});
const versionSecretIds = [...new Set(versionRequests.map((request) => request.secretId))];
const versions = [...new Set(versionRequests.map((request) => request.version))];
const versionRows = versionSecretIds.length > 0 && versions.length > 0
? await input.db
.select()
.from(companySecretVersions)
.where(
and(
inArray(companySecretVersions.secretId, versionSecretIds),
inArray(companySecretVersions.version, versions),
),
)
: [];
const versionsBySecretAndNumber = new Map(
versionRows.map((row) => [`${row.secretId}:${row.version}`, row]),
);
const metadata: EffectiveRunConfigSecretVersionMetadata[] = [];
for (const ref of refs) {
const secret = secretsById.get(ref.secretId);
if (!secret) {
metadata.push({
configPath: ref.configPath,
envKey: null,
secretId: ref.secretId,
version: typeof ref.versionSelector === "number" ? ref.versionSelector : "unresolved",
outcome: "failure",
});
continue;
}
const resolvedVersion = ref.versionSelector === "latest" || ref.versionSelector === undefined
? secret.latestVersion
: ref.versionSelector;
const versionRow = typeof resolvedVersion === "number"
? versionsBySecretAndNumber.get(`${secret.id}:${resolvedVersion}`) ?? null
: null;
metadata.push({
configPath: ref.configPath,
envKey: null,
secretId: secret.id,
version: resolvedVersion,
provider: secret.provider,
providerVersionRef: versionRow?.providerVersionRef ?? null,
outcome: versionRow ? "success" : "failure",
});
}
return metadata;
}
async function buildReusableSandboxLeaseFingerprint(input: {
db: Db;
companyId: string;
environment: Environment;
executionWorkspaceId: string | null;
agentId: string | null;
adapterType: string | null;
provider: string;
providerConfig: Record<string, unknown>;
providerPlugin?: {
id: string;
pluginKey: string;
packageName: string;
version: string;
} | null;
}): Promise<EffectiveRunConfigFingerprint> {
const secretMetadata = await buildEnvironmentSecretMetadataForLeaseFingerprint({
db: input.db,
companyId: input.companyId,
environment: input.environment,
});
return createEffectiveRunConfigFingerprints({
lease: {
companyId: input.companyId,
environment: {
id: input.environment.id,
driver: input.environment.driver,
},
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: input.provider,
providerPlugin: input.providerPlugin ?? null,
providerConfig: input.providerConfig,
secrets: secretMetadata,
},
secretManifest: secretMetadata,
}).leaseFingerprint;
}
function buildReusableSandboxLeaseScope(input: {
companyId: string;
environmentId: string;
@ -263,6 +409,7 @@ function buildReusableSandboxLeaseScope(input: {
adapterType: string | null;
provider: string;
config: Record<string, unknown>;
leaseFingerprint?: EffectiveRunConfigFingerprint | null;
providerMetadata?: Record<string, unknown> | null;
}): Record<string, unknown> | null {
if (!input.executionWorkspaceId || !input.agentId) return null;
@ -285,6 +432,9 @@ function buildReusableSandboxLeaseScope(input: {
adapterType,
config: input.config,
}),
...(input.leaseFingerprint
? { leaseFingerprint: serializeLeaseFingerprint(input.leaseFingerprint) }
: {}),
...(remoteCwd ? { remoteCwd } : {}),
...(workspaceSentinel ? { workspaceSentinel } : {}),
};
@ -299,24 +449,48 @@ function reusableSandboxLeaseScopeMatches(input: {
adapterType: string | null;
provider: string;
config: Record<string, unknown>;
leaseFingerprint?: EffectiveRunConfigFingerprint | null;
allowLegacyRuntimeFingerprint?: boolean;
}): boolean {
if (!input.executionWorkspaceId || !input.agentId) return false;
const scope = input.lease.metadata?.reusableSandboxLease;
if (!isRecord(scope)) return false;
const adapterType = input.adapterType ?? null;
return (
const baseScopeMatches =
scope.companyId === input.companyId &&
scope.environmentId === input.environmentId &&
scope.executionWorkspaceId === input.executionWorkspaceId &&
scope.agentId === input.agentId &&
scope.adapterType === adapterType &&
scope.provider === input.provider &&
scope.runtimeFingerprint === reusableRuntimeFingerprint({
provider: input.provider,
adapterType,
config: input.config,
})
);
scope.provider === input.provider;
if (!baseScopeMatches) return false;
const expectedLeaseFingerprint = input.leaseFingerprint?.fingerprint ?? null;
if (expectedLeaseFingerprint) {
const storedLeaseFingerprint = readLeaseFingerprint(scope.leaseFingerprint);
if (storedLeaseFingerprint) {
return storedLeaseFingerprint === expectedLeaseFingerprint;
}
if (!input.allowLegacyRuntimeFingerprint) return false;
}
return scope.runtimeFingerprint === reusableRuntimeFingerprint({
provider: input.provider,
adapterType,
config: input.config,
});
}
function reusableLeaseCanBeResumed(input: {
lease: Pick<EnvironmentLease, "status" | "heartbeatRunId">;
heartbeatRunId: string | null;
}): boolean {
if (input.lease.status === "released" || input.lease.status === "retained") return true;
return input.lease.status === "active" && input.heartbeatRunId !== null && input.lease.heartbeatRunId === input.heartbeatRunId;
}
function reusableLeaseCanBeCleanedUp(lease: Pick<EnvironmentLease, "status">): boolean {
return lease.status === "released" || lease.status === "retained";
}
export function findReusableSandboxLeaseId(input: {
@ -531,6 +705,23 @@ function createSandboxEnvironmentDriver(
};
}
async function cleanupObsoleteReusableSandboxLeases(input: {
environment: Environment;
leases: EnvironmentLease[];
reusableLeases: EnvironmentLease[];
}) {
const reusableIds = new Set(input.reusableLeases.map((lease) => lease.id));
for (const lease of input.leases) {
if (reusableIds.has(lease.id)) continue;
if (!reusableLeaseCanBeCleanedUp(lease)) continue;
await destroyReusableSandboxLease({
environment: input.environment,
lease,
failureReason: "lease_fingerprint_mismatch",
});
}
}
return {
driver: "sandbox",
@ -573,14 +764,38 @@ function createSandboxEnvironmentDriver(
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
const storedConfig = storedParsed.config;
const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig);
const supportsReusableLeases = pluginProvider.resolved.driver.supportsReusableLeases === true;
const leaseFingerprint =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? await buildReusableSandboxLeaseFingerprint({
db,
companyId: input.companyId,
environment: input.environment,
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
providerConfig: providerConfigForLease,
providerPlugin: {
id: pluginProvider.resolved.plugin.id,
pluginKey: pluginProvider.resolved.plugin.pluginKey,
packageName: pluginProvider.resolved.plugin.packageName,
version: pluginProvider.resolved.plugin.version,
},
})
: null;
// Ad-hoc tests (heartbeatRunId === null) must never resume an existing
// provider lease. If they did, releasing the test lease at the end of
// the probe would tear down the live heartbeat run that owns it.
// We also filter out leases whose policy is not reuse_by_environment
// and whose status is not reusable so non-reusable, cleanup-pending,
// or terminal rows cannot be matched.
const reusableExistingLeases =
const reusableCandidateLeases =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
@ -589,21 +804,35 @@ function createSandboxEnvironmentDriver(
? (await environmentsSvc.listLeases(input.environment.id))
.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
["active", "released", "retained"].includes(lease.status) &&
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId &&
reusableSandboxLeaseScopeMatches({
lease,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
config: sandboxConfigForLeaseMetadata(storedConfig),
}),
lease.metadata?.agentId === input.agentId,
)
: [];
const reusableExistingLeases = reusableCandidateLeases.filter((lease) =>
reusableSandboxLeaseScopeMatches({
lease,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
config: providerConfigForLease,
leaseFingerprint,
allowLegacyRuntimeFingerprint:
lease.status === "active" &&
input.heartbeatRunId !== null &&
lease.heartbeatRunId === input.heartbeatRunId,
}),
);
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
await cleanupObsoleteReusableSandboxLeases({
environment: input.environment,
leases: reusableCandidateLeases,
reusableLeases: reusableExistingLeases,
});
}
const reusableProviderLeaseId =
supportsReusableLeases &&
parsed.config.reuseLease &&
@ -694,7 +923,8 @@ function createSandboxEnvironmentDriver(
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
config: sandboxConfigForLeaseMetadata(storedConfig),
config: providerConfigForLease,
leaseFingerprint,
providerMetadata: sanitizedProviderMetadata,
})
: null;
@ -730,38 +960,72 @@ function createSandboxEnvironmentDriver(
// so non-reusable, cleanup-pending, or terminal rows can never be matched.
const builtinSandboxProvider = getBuiltinSandboxProvider(parsed.config.provider);
const supportsReusableLeases = builtinSandboxProvider?.supportsReusableLeases === true;
const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config);
const leaseFingerprint =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? await buildReusableSandboxLeaseFingerprint({
db,
companyId: input.companyId,
environment: input.environment,
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
providerConfig: providerConfigForLease,
})
: null;
const reusableCandidateLeases =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? (await environmentsSvc.listLeases(input.environment.id))
.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId,
)
: [];
const reusableExistingLeases = reusableCandidateLeases.filter((lease) =>
reusableSandboxLeaseScopeMatches({
lease,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
config: providerConfigForLease,
leaseFingerprint,
allowLegacyRuntimeFingerprint:
lease.status === "active" &&
input.heartbeatRunId !== null &&
lease.heartbeatRunId === input.heartbeatRunId,
}),
);
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
await cleanupObsoleteReusableSandboxLeases({
environment: input.environment,
leases: reusableCandidateLeases,
reusableLeases: reusableExistingLeases,
});
}
const reusableProviderLeaseId =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? (await environmentsSvc
.listLeases(input.environment.id)
.then((leases) =>
findReusableSandboxLeaseId({
config: parsed.config,
leases: leases.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
["active", "released", "retained"].includes(lease.status) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId &&
reusableSandboxLeaseScopeMatches({
lease,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
config: sandboxConfigForLeaseMetadata(parsed.config),
}),
),
}),
))
? findReusableSandboxLeaseId({ config: parsed.config, leases: reusableExistingLeases })
: null;
const reusableLease = reusableProviderLeaseId
? (await environmentsSvc.listLeases(input.environment.id)).find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
? reusableExistingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
: null;
let providerLease;
@ -806,7 +1070,8 @@ function createSandboxEnvironmentDriver(
agentId: input.agentId,
adapterType: input.adapterType,
provider: parsed.config.provider,
config: sandboxConfigForLeaseMetadata(parsed.config),
config: providerConfigForLease,
leaseFingerprint,
providerMetadata: providerLease.metadata,
})
: null;

File diff suppressed because it is too large Load Diff

View File

@ -244,6 +244,7 @@ export type RuntimeSecretManifestEntry = {
secretKey: string;
version: number;
provider: SecretProvider;
providerVersionRef?: string | null;
outcome: "success" | "failure";
errorCode?: string | null;
};
@ -696,6 +697,7 @@ export function secretService(db: Db) {
secretKey: secret.key,
version: resolvedVersion,
provider: providerId,
providerVersionRef: versionRow.providerVersionRef,
outcome: "success",
},
};

View File

@ -1471,6 +1471,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
if (strategy !== "git_worktree") {
if (!await directoryExists(cwd)) {
return null;
}
return realized;
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);

View File

@ -431,6 +431,8 @@ function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) {
switch (phase) {
case "worktree_prepare":
return "Worktree setup";
case "workspace_config_freshness":
return "Config freshness";
case "workspace_provision":
return "Provision";
case "workspace_teardown":
@ -1671,6 +1673,9 @@ function ConfigurationTab({
hideInstructionsFile={hideInstructionsFile}
sectionLayout="cards"
/>
<p className="text-xs text-muted-foreground">
Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session.
</p>
<TrustPresetSection
permissions={agent.permissions}
@ -2114,6 +2119,9 @@ function PromptsTab({
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Saved instructions affect the next run. Active runs keep the instructions they started with, and instruction changes may start a fresh adapter session.
</p>
<Collapsible defaultOpen={currentMode === "external"}>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">

View File

@ -1049,7 +1049,7 @@ export function CompanyEnvironments() {
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
<DialogTitle>{editingEnvironmentId ? "Edit environment" : "Add environment"}</DialogTitle>
<DialogDescription>
Configure a reusable execution target for your agents.
Configure a reusable execution target for your agents. Saved changes affect future runs; Paperclip may start fresh sessions or sandbox leases after environment config changes.
</DialogDescription>
</DialogHeader>

View File

@ -259,6 +259,25 @@ function Field({
);
}
function workspaceOperationPhaseLabel(phase: string) {
switch (phase) {
case "worktree_prepare":
return "Worktree setup";
case "workspace_config_freshness":
return "Config freshness";
case "workspace_provision":
return "Provision";
case "workspace_teardown":
return "Teardown";
case "worktree_cleanup":
return "Worktree cleanup";
case "workspace_finalize":
return "Finalize";
default:
return phase;
}
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
@ -845,7 +864,7 @@ export function ExecutionWorkspaceDetail() {
<CardHeader>
<CardTitle>Workspace settings</CardTitle>
<CardDescription>
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace. Saved changes affect future runs; Paperclip may refresh or replace a reused workspace when config changes.
</CardDescription>
<CardAction>
<Button
@ -1173,7 +1192,7 @@ export function ExecutionWorkspaceDetail() {
<div key={operation.id} className="rounded-none border border-border/80 bg-background px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
<div className="text-sm font-medium">{operation.command ?? workspaceOperationPhaseLabel(operation.phase)}</div>
<div className="text-xs text-muted-foreground">
{formatDateTime(operation.startedAt)}
{operation.finishedAt ? `${formatDateTime(operation.finishedAt)}` : ""}