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:
parent
5bd6c6ec3c
commit
a8f0ebaa80
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export type WorkspaceOperationPhase =
|
||||
| "worktree_prepare"
|
||||
| "workspace_config_freshness"
|
||||
| "workspace_provision"
|
||||
| "workspace_teardown"
|
||||
| "worktree_cleanup"
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}` : ""}
|
||||
|
|
|
|||
Loading…
Reference in New Issue