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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user