fix(server): enforce agent secret binding sync across lifecycle flows (#8307)

## Thinking Path

> - Paperclip is the control plane people use to create, configure, and
run AI agents for work.
> - This change sits in the server-side agent lifecycle and
secret-binding subsystem, where adapter config `env` entries can
reference company secrets.
> - An incident (while trying to configure a Novita sandbox) showed that
an agent can reach a broken runtime state if `adapterConfig.env`
contains `secret_ref` entries but the matching `company_secret_bindings`
rows are missing.
> - The immediate run-path guard and error-surfacing work made the
failure diagnosable, but they did not fully prevent new broken agents
from being created.
> - The risk came from create and approval flows being responsible for
remembering to sync bindings at each call site, which is easy to miss as
new flows are added.
> - This pull request moves the invariant into `agentService`
create/update/activate paths, keeps the existing hire-flow fix, and adds
regression coverage for create, update, and legacy pending-approval
recovery.
> - The benefit is that agent secret binding integrity is enforced
closer to the data mutation point, so future callers inherit the
protection automatically.

## Linked Issues or Issue Description

Refs #8309

### What happened?
A Paperclip agent could persist `adapterConfig.env` `secret_ref` entries
without matching agent-scoped `company_secret_bindings` rows. When that
happened, the config UI could still look configured, but the real run
path failed pre-dispatch because the secret was not actually bound to
that agent.

### Expected behavior
Every normal agent create, config-update, and pending-approval
activation flow should leave the agent with secret bindings that match
its persisted secret-ref env config.

### Steps to reproduce
1. Create or activate an agent through a flow that persists
`adapterConfig.env` secret refs without synchronizing
`company_secret_bindings`.
2. Observe that the config state can still appear populated.
3. Start a run for that agent.
4. Observe that pre-dispatch binding validation fails because the secret
reference exists but the agent binding does not.

### Deployment mode
Local dev (`pnpm dev`)

### Installation method
Built from source (`pnpm dev` / `pnpm build`)

### Agent adapter(s) involved
- Claude Code
- Not adapter-specific (core bug)

### Database mode
Embedded PGlite / embedded local dev database flow

### Access context
Board (human operator) created or approved the agent; agent runtime
later consumed the config.

### Additional context
This PR focuses on preventing new broken states from normal service
flows and on backfilling the covered legacy pending-approval activation
path.

## What Changed

- Kept the existing branch-local hire-flow fix that synchronized
bindings for route and approval paths.
- Moved the binding integrity invariant into `agentService.create()`,
`agentService.update()` when `adapterConfig` changes, and
`agentService.activatePendingApproval()`.
- Added `server/src/__tests__/agents-service-secret-bindings.test.ts`
covering create-time sync, update-time resync, and backfill for legacy
pending-approval agents.
- Removed now-redundant route-layer and approval-layer binding sync
calls once the service layer became authoritative.
- Simplified the affected unit tests so route/approval tests no longer
assert service-owned binding writes directly.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run
server/src/__tests__/agents-service-secret-bindings.test.ts
server/src/__tests__/approvals-service.test.ts
server/src/__tests__/agent-skills-routes.test.ts`

## Risks

- Low to medium risk.
- This changes where secret-binding synchronization is enforced, so any
unexpected caller that relied on upper-layer manual sync behavior could
behave differently.
- Agent create/update/activation flows now perform binding
synchronization consistently, which adds binding-table writes at those
mutation points.
- This PR does not retroactively scan and heal every already-broken
historical agent row; it prevents and backfills through the covered
service flows.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex / GPT-5 Codex class model via `codex_local`
- Session model family: GPT-5 Codex
- Tool-assisted coding with shell, git, HTTP, and local test execution
- Reasoning mode: medium interactive tool-use workflow

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [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>
This commit is contained in:
Devin Foley
2026-06-18 21:26:36 -07:00
committed by GitHub
parent 67c98323b0
commit fc95699fde
23 changed files with 1003 additions and 104 deletions
+62 -3
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { Environment } from "@paperclipai/shared";
import { supportsAdapterModelRefresh } from "./AgentConfigForm";
import { describe, expect, it, vi } from "vitest";
import type { AdapterEnvironmentTestResult, Environment } from "@paperclipai/shared";
import {
getAgentConfigTestActionLabel,
runAgentConfigEnvironmentTest,
supportsAdapterModelRefresh,
} from "./AgentConfigForm";
import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment";
describe("supportsAdapterModelRefresh", () => {
@@ -16,6 +20,61 @@ describe("supportsAdapterModelRefresh", () => {
});
});
describe("agent config test action", () => {
it("labels dirty edit-mode tests as save-and-test", () => {
expect(getAgentConfigTestActionLabel({ isCreate: false, isDirty: true })).toBe("Save + Test");
expect(getAgentConfigTestActionLabel({ isCreate: false, isDirty: false })).toBe("Test");
expect(getAgentConfigTestActionLabel({ isCreate: true, isDirty: true })).toBe("Test");
});
it("saves a dirty edit draft before running the environment test", async () => {
const callOrder: string[] = [];
const saveDraft = vi.fn(async () => {
callOrder.push("save");
});
const runTest = vi.fn(async (): Promise<AdapterEnvironmentTestResult> => {
callOrder.push("test");
return {
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
};
});
await runAgentConfigEnvironmentTest({
isCreate: false,
isDirty: true,
saveDraft,
runTest,
});
expect(saveDraft).toHaveBeenCalledTimes(1);
expect(runTest).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["save", "test"]);
});
it("runs create-mode tests without saving first", async () => {
const saveDraft = vi.fn(async () => {});
const runTest = vi.fn(async (): Promise<AdapterEnvironmentTestResult> => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}));
await runAgentConfigEnvironmentTest({
isCreate: true,
isDirty: true,
saveDraft,
runTest,
});
expect(saveDraft).not.toHaveBeenCalled();
expect(runTest).toHaveBeenCalledTimes(1);
});
});
function makeEnvironment(overrides: Partial<Environment>): Environment {
return {
id: "env-1",
+62 -19
View File
@@ -95,7 +95,7 @@ type AgentConfigFormProps = {
| {
mode: "edit";
agent: Agent;
onSave: (patch: Record<string, unknown>) => void;
onSave: (patch: Record<string, unknown>) => void | Promise<unknown>;
isSaving?: boolean;
}
);
@@ -116,6 +116,22 @@ export function supportsAdapterModelRefresh(adapterType: string): boolean {
return adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "acpx_local";
}
export function getAgentConfigTestActionLabel(input: { isCreate: boolean; isDirty: boolean }): string {
return !input.isCreate && input.isDirty ? "Save + Test" : "Test";
}
export async function runAgentConfigEnvironmentTest(input: {
isCreate: boolean;
isDirty: boolean;
saveDraft?: () => void | Promise<unknown>;
runTest: () => Promise<AdapterEnvironmentTestResult>;
}) {
if (!input.isCreate && input.isDirty) {
await input.saveDraft?.();
}
return await input.runTest();
}
function isOverlayDirty(o: AgentConfigOverlay): boolean {
return (
Object.keys(o.identity).length > 0 ||
@@ -307,9 +323,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setOverlay({ ...emptyOverlay });
}, []);
const handleSave = useCallback(() => {
const handleSave = useCallback(async () => {
if (isCreate || !isDirty) return;
props.onSave(buildAgentUpdatePatch(props.agent, overlay));
await props.onSave(buildAgentUpdatePatch(props.agent, overlay));
}, [isCreate, isDirty, overlay, props]);
useEffect(() => {
@@ -508,11 +524,36 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
});
},
});
const testEnvironmentDisabled = testEnvironment.isPending || !selectedCompanyId;
const [testActionPending, setTestActionPending] = useState(false);
const [testActionError, setTestActionError] = useState<string | null>(null);
const testActionLabel = getAgentConfigTestActionLabel({ isCreate, isDirty });
const isSavePending = !isCreate && Boolean(props.isSaving);
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId;
const runEnvironmentTest = useCallback(async () => {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
}
setTestActionPending(true);
setTestActionError(null);
testEnvironment.reset();
try {
return await runAgentConfigEnvironmentTest({
isCreate,
isDirty,
saveDraft: !isCreate ? handleSave : undefined,
runTest: () => testEnvironment.mutateAsync(),
});
} catch (error) {
setTestActionError(error instanceof Error ? error.message : "Environment test failed");
throw error;
} finally {
setTestActionPending(false);
}
}, [selectedCompanyId, isCreate, isDirty, handleSave, testEnvironment]);
const triggerTestEnvironment = useCallback(() => {
if (testEnvironmentDisabled) return;
testEnvironment.mutate();
}, [testEnvironment.mutate, testEnvironmentDisabled]);
void runEnvironmentTest().catch(() => undefined);
}, [runEnvironmentTest, testEnvironmentDisabled]);
useEffect(() => {
if (!showAdapterTestEnvironmentButton || !props.onTestActionChange) return;
@@ -526,7 +567,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
if (!showAdapterTestEnvironmentButton || !props.onTestActionStateChange) return;
props.onTestActionStateChange({
disabled: testEnvironmentDisabled,
pending: testEnvironment.isPending,
pending: testActionPending,
});
return () => {
props.onTestActionStateChange?.({ disabled: true, pending: false });
@@ -535,23 +576,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
showAdapterTestEnvironmentButton,
props.onTestActionStateChange,
testEnvironmentDisabled,
testEnvironment.isPending,
testActionPending,
]);
useEffect(() => {
if (!props.onTestFeedbackChange) return;
props.onTestFeedbackChange({
errorMessage: testEnvironment.error instanceof Error
? testEnvironment.error.message
: testEnvironment.error
? "Environment test failed"
: null,
errorMessage: testActionError
?? (testEnvironment.error instanceof Error
? testEnvironment.error.message
: testEnvironment.error
? "Environment test failed"
: null),
result: testEnvironment.data ?? null,
});
return () => {
props.onTestFeedbackChange?.({ errorMessage: null, result: null });
};
}, [props.onTestFeedbackChange, testEnvironment.data, testEnvironment.error]);
}, [props.onTestFeedbackChange, testActionError, testEnvironment.data, testEnvironment.error]);
// Current model for display
const currentModelId = isCreate
@@ -895,7 +937,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onClick={triggerTestEnvironment}
disabled={testEnvironmentDisabled}
>
{testEnvironment.isPending ? "Testing..." : "Test"}
{testActionPending ? `${testActionLabel}...` : testActionLabel}
</Button>
)}
</div>
@@ -955,11 +997,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{showInlineAdapterTestEnvironmentFeedback && testEnvironment.error && (
{showInlineAdapterTestEnvironmentFeedback && (testActionError || testEnvironment.error) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{testEnvironment.error instanceof Error
? testEnvironment.error.message
: "Environment test failed"}
{testActionError
?? (testEnvironment.error instanceof Error
? testEnvironment.error.message
: "Environment test failed")}
</div>
)}
+7
View File
@@ -44,6 +44,13 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
<PropertyRow label="Status">
<AgentStatusBadge status={agent.status} />
</PropertyRow>
{lastErrorIsActive && agent.errorReason && (
<PropertyRow label="Error reason">
<span className="text-xs text-red-600 dark:text-red-400 break-words min-w-0">
{agent.errorReason}
</span>
</PropertyRow>
)}
<PropertyRow label="Role">
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
</PropertyRow>
@@ -47,6 +47,7 @@ const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
missing_disposition: "Missing Disposition",
stranded_assigned_issue: "Stranded Task",
workspace_validation: "Workspace Validation",
configuration_validation: "Configuration Validation",
active_run_watchdog: "Active Watchdog",
issue_graph_liveness: "Graph Liveness",
};
@@ -57,6 +58,8 @@ const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
"Paperclip retried this task's last run and it still has no live execution path.",
workspace_validation:
"Paperclip stopped this run because the task's git workspace could not be validated.",
configuration_validation:
"Paperclip stopped before dispatching this run because required secret/env bindings are missing.",
active_run_watchdog:
"The active run has been silent. Recovery is observing without interrupting it.",
issue_graph_liveness:
+1 -1
View File
@@ -1659,7 +1659,7 @@ function ConfigurationTab({
<AgentConfigForm
mode="edit"
agent={agent}
onSave={(patch) => updateAgent.mutate(patch)}
onSave={(patch) => updateAgent.mutateAsync(patch)}
isSaving={isConfigSaving}
adapterModels={adapterModels}
onDirtyChange={onDirtyChange}