diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index 2acf62e5248..c4c28fedf37 100644
--- a/doc/PRODUCT.md
+++ b/doc/PRODUCT.md
@@ -49,6 +49,12 @@ start execution. This preference does not change the org chart or permissions.
### Agent Execution
+Agents have an **Advanced** tab for editing their settings across all configuration tabs
+as a versioned YAML document. Instructions, skills, tools, channels, trust,
+secret operations, API keys, and revision restore use their existing domain APIs.
+It edits the same configuration as the regular settings pages, validates before saving, preserves masked secrets, and rejects
+stale edits. See [Agent configuration YAML](agent-configuration-yaml.md).
+
Existing Claude, Codex, and Grok Build agents can upgrade from the legacy runner on their
Harness / Runtime page when Paperclip Runner is the qualified default for their
environment. One action saves pending edits and translates compatible settings
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index e829a2a5633..710ad6e6a2b 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -324,6 +324,15 @@ Invariants:
- no cycles in reporting tree
- `terminated` agents cannot be resumed
+Agent configuration also has a versioned YAML representation in the Advanced
+tab, including instruction text, skills, tools, channels, permissions, secrets,
+API key operations, and revision restore. Resource saves use their owning APIs
+and expose partial failures; they are not a cross-resource transaction.
+[Agent configuration YAML](agent-configuration-yaml.md) defines its schema,
+replacement semantics, secret preservation, and revision preconditions. It uses
+the existing configuration and resource APIs, retaining their authorization
+and approval gates. Agent lifecycle commands remain separate actions.
+
## 7.3 `agent_api_keys`
- `id` uuid pk
diff --git a/doc/agent-configuration-yaml.md b/doc/agent-configuration-yaml.md
new file mode 100644
index 00000000000..c985b734750
--- /dev/null
+++ b/doc/agent-configuration-yaml.md
@@ -0,0 +1,347 @@
+# Agent configuration YAML
+
+Status: v1, October 28, 2026.
+
+The **Advanced** tab at `/:companyPrefix/agents/:agentRef/advanced` edits an
+agent's settings as one YAML document. It covers the configuration tabs through
+their existing APIs. The database and resource services remain authoritative;
+this is not a file on the agent's computer or a harness-specific format.
+
+## Coverage
+
+| Settings surface | YAML location | Save behavior |
+| --- | --- | --- |
+| Agent identity / profile | `spec.name`, `role`, `title`, `icon`, `appearance`, `reportsTo`, `capabilities`, `metadata` | Existing agent update, including avatar appearance and reporting relationships. |
+| Harness / Runtime | `spec.adapterType`, `adapterConfig`, `runtimeConfig`, `defaultEnvironmentId`, `budgetMonthlyCents` | Existing adapter, environment, AI connection, heartbeat, run-policy and budget validation. Adapter-specific extensions are preserved. |
+| Instructions | `spec.instructions` | Bundle mode, root, entry file, and editable text files. Managed files and entry revisions enforce concurrency preconditions; omitted text files are deleted. Legacy prompt templates remain in `adapterConfig`. |
+| Skills | `spec.skills` | Desired skill keys and version pins, synchronized through the skills API. |
+| Secrets & variables | `spec.adapterConfig.env`, `adapterConfig["access."]`, `spec.secrets` | Environment values, secret references, API-delivery bindings, creation, rotation, metadata/status updates, and proposal decisions. |
+| Tools | `spec.tools` | Direct connection installs, profile bindings and definitions, and agent-scoped policies. Inherited access is visible under `observed`. |
+| Channels | `spec.channels`, `spec.channelOperations` | Existing endpoint settings, communication instructions, execution defaults, resource selection, setup/credentials and lifecycle actions, creation, and identity revocation. |
+| Permissions / Trust | `spec.permissions` | Agent creation, skill creation, task assignment, trust preset and full authorization policy, including allowed projects in `authorizationPolicy.trustBoundary.projectIds`. |
+| API Keys | `spec.apiKeys` | Explicit create and revoke operations; key metadata is under `observed.apiKeys`. New tokens appear once outside the YAML. |
+| Revisions | `spec.revisions.restore` | Restore an existing revision, as the only operation in a save; history is under `observed.revisions`. |
+
+Read-only information stays read-only: organization-wide inherited access,
+immutable history, binary instruction assets, live status/spend, and provider
+connection state. Interactive provider login/OAuth and approval flows still use
+their normal UI. The YAML can select existing accounts/connections and configure
+channel credentials; it cannot synthesize provider consent or export stored
+credential values. AgentMail provisioning remains owned by its connection flow.
+
+## Document contract
+
+```yaml
+apiVersion: paperclip/v1
+kind: Agent
+spec:
+ name: Codie
+ role: engineer
+ title: Software engineer
+ icon: null
+ appearance:
+ schemaVersion: 1
+ characterVersion: cap-v1
+ paletteId: bubblegum-sky
+ reportsTo: null
+ capabilities: |
+ Implement features, review code, and maintain tests.
+ adapterType: codex_local
+ adapterConfig:
+ model: gpt-5
+ cwd: /workspace/project
+ env:
+ SERVICE_TOKEN:
+ type: secret_ref
+ secretId: 11111111-1111-4111-8111-111111111111
+ version: latest
+ runtimeConfig:
+ heartbeat:
+ enabled: true
+ intervalSec: 3600
+ wakeOnDemand: true
+ defaultEnvironmentId: null
+ budgetMonthlyCents: 10000
+ metadata: null
+ permissions:
+ canCreateAgents: false
+ canCreateSkills: true
+ canAssignTasks: false
+ trustPreset: standard
+ instructions:
+ mode: managed
+ rootPath: /managed/agents/codie
+ entryFile: AGENTS.md
+ files:
+ AGENTS.md: |
+ # Codie
+ Read the task, make focused changes, and verify the result.
+ references/workflow.md: |
+ Run the relevant tests before handing work back.
+ skills:
+ - key: paperclip
+ versionId: null
+ tools:
+ connections: []
+ profiles: []
+ policies: []
+ channels: []
+ secrets:
+ create: []
+ rotate: []
+ update: []
+ proposals: []
+ channelOperations:
+ create: []
+ setup: []
+ revokeIdentityLinks: []
+ apiKeys:
+ create: []
+ revoke: []
+ revisions:
+ restore: null
+observed: {}
+```
+
+This illustrates the format; model, paths, and secret IDs depend on the
+installation. Edit the document loaded for the agent. Do not replace its
+`observed` block with the example's empty block.
+
+`apiVersion`, `kind`, `spec`, and every core field from `name` through `metadata`
+are required, including explicit `null` for unset nullable fields. Resource
+sections (`permissions` onward) are optional: omitting an entire section
+preserves it. The editor emits all
+sections. Unknown fields at the document, spec, or structured resource level
+are errors. Open adapter/runtime/metadata maps retain their existing extension
+contracts. Future incompatible changes require a new API version.
+
+Executable contracts:
+[core document](../packages/shared/src/agent-configuration.ts) and
+[resource sections](../packages/shared/src/agent-configuration-resources.ts).
+Resource inputs reuse the owning APIs' validators, including trust-policy,
+secret, channel, skill, and tool validators.
+
+## Resource semantics
+
+### Instructions and skills
+
+`instructions.mode` is `managed`, `external`, or `null` for an unconfigured
+bundle. `external` requires an absolute `rootPath`. File names and `entryFile`
+are relative paths; traversal and absolute file names are rejected. Each file
+value is its text content (up to 1 MiB). Adding a mapping creates a file;
+changing one edits it; removing one deletes it. The entry file cannot be
+removed. Binary/read-only/virtual files are described in
+`observed.instructionFiles`. A legacy virtual prompt template is edited using
+`adapterConfig.promptTemplate` rather than a second writable copy.
+
+Changing the mode, root, or entry file is a separate save from editing file
+contents. That save loads the destination files without writing the old YAML's
+contents over them. The native bundle service may seed an empty destination or
+a missing entry file from the previous bundle. Review the freshly loaded files
+before editing them. A managed root is assigned by Paperclip; choose `external`
+mode to select another directory.
+
+Skills accept a key string or `{ key, versionId }`; `versionId: null` follows
+the current version. An empty list unassigns skills through the normal sync
+API. These keys are organization-library references, not embedded skill source.
+
+The `instructionsBundleMode`, `instructionsRootPath`, `instructionsEntryFile`,
+`instructionsFilePath`, and `paperclipSkillSync` adapter keys are represented by
+the dedicated sections. Direct edits to those adapter keys are rejected so two
+sections cannot disagree about the same setting.
+
+### Tools
+
+`tools.connections` contains `{ connectionId, installed }` for each available
+connection. These are direct agent installations; inherited company
+installations appear in `observed.connections`. Removing a direct installation
+row has the same effect as `installed: false`. Saves preserve other agents'
+installations. Company installations cannot be overridden here.
+The installation service can add an access profile and binding for an installed
+connection. The canonical reload includes those generated profiles.
+
+Each profile contains:
+
+```yaml
+profileId: null # null creates a profile; an existing ID binds/edits it
+updateSharedDefinition: false
+priority: 100
+metadata: null
+configuration:
+ profileKey: engineering
+ name: Engineering
+ description: null
+ status: active
+ defaultAction: deny
+ metadata: null
+ entries:
+ - selectorType: tool_name
+ effect: include
+ toolName: get_repository
+```
+
+The profile configuration uses the native profile/entry schema. Profiles can
+be shared: editing a profile's definition affects every binding to that profile.
+When other targets share it, changes to the definition require explicitly
+setting `updateSharedDefinition: true`. The flag resets after reload; changing
+only the flag does not write anything. Binding an unchanged shared definition
+does not require the flag. Use `profileId: null` for a separate profile.
+Removing a profile row only unbinds it from this agent. Available profile
+configurations are listed under `observed.availableProfiles`.
+
+Keep existing profile and policy fields explicit. Removing a field from an
+existing definition is rejected because the native PATCH would otherwise
+preserve its old value. Use the field's supported empty value to clear it.
+
+Policies use `{ id, configuration }`, with `id: null` creating a policy.
+Configuration includes `name`, `description`, `policyType`, `priority`,
+`enabled`, `selectors`, `conditions`, and `config`. Policies in this section
+must target only this agent; newly created policies are scoped to it. Removing
+a row deletes that agent-scoped policy. Shared/inherited policies are read-only
+under `observed.inheritedPolicies`.
+
+### Channels
+
+Each `channels` row has immutable `id` and `provider`, plus `settings` and
+`resources`. Settings follow the native endpoint update contract:
+`executionDefaults`, `communicationInstructions`, `allowUnlinkedPeople`,
+`allowDirectMessages`, `requireAtMention`, `allowGroupChats`, `slackApp`, and
+`slackSetupMethod`, where applicable. Keep saved settings explicit; omission
+cannot silently clear a PATCH field. Resources contain `{ id, enabled,
+executionDefaults }`; removing a resource row disables it. Removing a channel
+row removes the endpoint using its existing setup API.
+
+`channelOperations.create` accepts the native creation fields (`provider`,
+optional `applicationId`, `name`, `slackApp`, `slackSetupMethod`); the current
+agent ID is supplied by the editor. Save creation first to obtain its endpoint
+ID before adding setup commands. `setup` accepts `{ id, input }`, where input
+has an `action` (`configure`, `verify`, `pause`, `resume`, `reconnect`, `remove`)
+and optional `credentials` or Photon configuration. `revokeIdentityLinks`
+accepts `{ endpointId, principalId }`. Provider authorization is still enforced
+by the channel service. IDs, resources, linked people, and state are available
+under `observed.channels`.
+
+An endpoint cannot be removed and also edited, configured, or have its identities
+revoked in the same save. Duplicate setup commands and identity revocations are
+rejected before any writes.
+
+### Secrets, API keys, and revisions
+
+Use `secret_ref` and `user_secret_ref` bindings for existing credentials.
+`secrets.create` accepts `{ secret, bind: { env: [], api: [] } }`; `secret` follows
+the create-secret contract (name/key, provider, value or external reference,
+provider configuration and metadata). After creation, `bind` can attach its ID
+to environment names or API aliases. An empty bind creates without attaching.
+`rotate` and `update` accept `{ id, input }` using their native contracts.
+`proposals` accepts `{ id, decision: approve|reject, reason? }`; rejection
+requires a reason. Secrets and pending proposals are listed without values
+under `observed.secrets` and `observed.secretProposals`.
+
+New secrets must use distinct binding targets. A target cannot also be edited
+in `adapterConfig` in the same save. Binding targets are checked again before
+creation and before the revision-guarded binding update. If creation succeeds
+but binding fails, the receipt identifies the created secret; reload before
+deciding how to bind it.
+
+`apiKeys.create` accepts `{ name, scope }`; scope uses the existing agent key
+schema, with `{ kind: standard }` as the default. `apiKeys.revoke` contains key
+IDs. Tokens returned by creation appear outside the YAML and are not stored in
+the configuration query cache. Copy them before leaving the page. Previously
+issued tokens cannot be retrieved.
+Duplicate creation names within one save are rejected.
+
+`revisions.restore` accepts an existing revision ID or `null`. Restore must be
+the only change in that save. It uses the same rollback operation as the
+Revisions tab and restores only the fields covered by that revision; it is not
+a rollback of all external resources.
+
+Operation lists reset to empty after a successful save/reload. Deleting an
+`observed` history row does not revoke a key or restore a revision: editing
+`observed` is rejected. Lifecycle commands for the agent itself (pause, resume,
+terminate) are actions outside configuration.
+
+## YAML, secrets, and save guarantees
+
+The parser accepts one YAML 1.2 mapping containing JSON-compatible values.
+Duplicate keys, multiple documents, aliases, anchors, explicit tags, merge
+keys, non-string mapping keys, non-finite numbers, and prototype-related keys
+are rejected. Documents are limited to 8 MiB and 50 levels of nesting. Syntax
+errors include a location; schema errors identify a field path. There is no
+interpolation, shell evaluation, include directive, or parser file access.
+Comments and formatting are not persisted; saving/reloading regenerates YAML.
+Changing only comments or mapping order does not write resources.
+The editor shows **No configuration changes** and keeps Save disabled when the
+operation plan is empty, including when an optional section is omitted.
+
+The initial core read checks configuration access before related resources are
+loaded. Failed reads are shown as errors, never substituted with empty resource
+lists. The editor validates document structure, known references, explicit
+clears, and conflicting operations before enabling Save. Expand the change
+summary to review the ordered operations, including removals and shared profile
+edits. Domain-specific authorization, validation, and normalization still run
+at each API write; not every server rejection can be predicted in the browser.
+
+Saving refreshes the snapshot before the first mutation. A changed core revision or an
+edited resource section that has changed elsewhere rejects the save. Concurrent
+changes to untouched resource sections are preserved. These are preflight
+checks, not a transaction spanning all resources.
+
+Core changes use `PATCH /api/agents/:id` with
+`expectedConfigurationRevision`, checked again under the row lock. Removing an
+adapter key uses `replaceAdapterConfig: true`. The opaque revision uses a
+domain-separated HMAC with the instance's private signing key. Credential edits
+invalidate an open draft without exposing a public credential digest. Rotating
+the signing key requires reloading open editors. Server processes for one
+instance must share that key. File-based rotation takes effect on the next
+revision calculation without a server restart. Managed instruction files and
+entry revisions enforce hash/revision preconditions. External supporting file
+writes/deletions do not enforce these preconditions. Other resources retain
+their own API guarantees; changes between preflight and an unversioned resource
+write are not atomically protected. Every mutation uses its existing company/actor authorization,
+approval, validation, audit and revision behavior.
+
+A multi-resource save runs sequentially and is **not atomic**. On an interrupted
+save, the editor retains the draft, reports completed steps and the failing
+step, preserves any credential receipts, and disables Save until **Reload
+saved**. Reload reveals current state and clears pending operations so retrying
+cannot blindly duplicate a successful creation. A network failure may mean the
+last operation committed even when its response did not arrive. No writes are
+automatically retried. Double-clicking Save starts one operation, the editor
+and Reload are disabled during saving, and agent navigation is held until it
+settles. Dirty drafts use the agent page's unsaved-navigation guard. Closing the
+browser cannot cancel or roll back requests already sent.
+
+A successful save reloads canonical state, clears command lists, and displays
+any normalization warnings returned by the server. CEO agents and agents that
+can create agents must explicitly retain task assignment authority.
+
+Stored inline credentials use `***REDACTED***`. An unchanged marker at its
+original location preserves its saved value. Removing the key removes that
+setting; replacing the full value supplies a new value under normal validation.
+For revision-guarded core saves, the server restores unchanged masked subtrees
+before validation; moved/partially edited redactions are rejected. Secret
+references are never resolved into values. New secret/channel credential inputs
+are one-time commands and disappear after canonical reload. This document is
+an editable settings view, not a portable credential backup.
+
+No background polling, database migration, or new authorization bypass is
+introduced by the editor.
+
+## Verification
+
+Run the browser acceptance suite against its disposable checkout instance:
+
+```sh
+pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/agent-configuration.spec.ts
+```
+
+The suite checks resource saves and clears, one-time key receipts, secret
+redaction, stale drafts, a lost creation response, instruction relocation, and
+revision restore through real APIs. It enables and restores the Beta skills and
+chat connector flags. Its local MCP fixture provides a connection target; it
+does not qualify provider login or agent execution.
+
+The lifecycle regressions can use a dedicated external PostgreSQL database when
+embedded PostgreSQL is unavailable. Set
+`PAPERCLIP_AGENT_LIFECYCLE_TEST_DATABASE_URL` to a database ending in `_test`
+before running `server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts`.
diff --git a/doc/plans/2026-10-28-cancellation-deadlock-acceptance.md b/doc/plans/2026-10-28-cancellation-deadlock-acceptance.md
new file mode 100644
index 00000000000..491240ace26
--- /dev/null
+++ b/doc/plans/2026-10-28-cancellation-deadlock-acceptance.md
@@ -0,0 +1,77 @@
+# Cancellation deadlock retry acceptance
+
+This existing correctness slice (#17895) retries only the operator Stop database
+transaction after PostgreSQL reports `40P01`, at most three times. Provider Stop,
+workspace settlement, and activity publication remain after commit. This is not
+a new polling or scheduling duty.
+
+## Current parent and outstanding review
+
+The previous published head is
+`fffc8d0c46fbbeb3da4a23a36e638fefd3b8fe3d`, originally based on native progress
+`08d07ead105dc89c2af9b62f4d90bff8cc93540c`. The slice is prepared by a normal
+merge of verified native progress `6f365285f5b233a89acc714eadfead5ab145100e`.
+All four original changed-file hunks retain their exact content and context.
+Only the run-control blob index hashes differ because the parent also changed
+that file. The automatic composition is clean and preserves those parent edits.
+
+The prior Ponytail review said Ship with an actionable coverage request: existing
+deadlock tests did not reach a correlated native failed run's claimed state before
+rollback. The follow-up adds two real PostgreSQL cases in the existing run-control
+suite, with no new production behavior:
+
+1. Keep retry authority: claim the correlated Stop, fence the failed native run,
+ cancel its real board terminal handoff, and persist cancellation activity inside
+ the first transaction. Raise PostgreSQL `40P01` before commit. Retry succeeds;
+ exactly one cancellation activity is durable and exactly one live notice is
+ published. The correlation belongs to the committed attempt.
+2. Revoke retry authority: after that aborted transaction rolls back, an independent
+ coordinator update retires retry eligibility. The second attempt returns 409,
+ preserves the failed run and uncancelled handoff, publishes no cancellation
+ activity, and performs no provider or workspace cleanup.
+
+The existing actual two-connection lock-cycle regression and the bounded retry/
+non-deadlock cases remain in the complete suite. The new tests exercise claims
+and activity produced before a real PostgreSQL transaction abort; they do not
+infer correctness from a mocked retry callback alone.
+
+## Evidence and failure accounting
+
+- Both new cases pass against the retained production reset logic.
+- Removing only `publications.length = 0` makes the preserved-authority case fail:
+ two live cancellation notices appear for one committed activity. Production was
+ restored byte-for-byte to its integrated index before further validation.
+ `/tmp/sleep-aux-native-retry-mutation-proof.json` records that comparison.
+- The negative-control harness initially expected an untruncated assertion line;
+ Vitest abbreviates that line. Its actual named failure and 1-versus-2 result were
+ inspected, and the matcher was corrected without rerunning or altering production.
+- Typechecking caught an optional live-event payload in the new assertion. The
+ assertion now uses optional access. Preserve the original failed typecheck at
+ `/tmp/sleep-aux-current-parent-types.log`; the final typecheck passes at
+ `/tmp/sleep-aux-current-parent-types-final.log`.
+- **228 distinct complete cases pass:** all 48 run-control cases, including the two
+ additions, and all 180 native-session runtime cases, including the existing
+ controlled timeout/late-append fixture. The two targeted probes are included in
+ 48 and are not counted again.
+- Server typechecking, build, and module-boundary checks pass. `server/tsconfig.json`
+ excludes `src/__tests__`, but includes service-local test files such as
+ `run-control.test.ts`; typechecking does not replace test execution. Earlier
+ statements about tests omitted by production typechecking must be read with
+ that path boundary.
+
+The three PostgreSQL probes use separate new disposable databases on the owned
+local cluster. Full-run results and exit receipts are in
+`/tmp/sleep-aux-current-parent-{types-final,build,boundaries,control,runner}.log`.
+The targeted positive and mutation logs are
+`/tmp/sleep-aux-native-retry-regression.log` and
+`/tmp/sleep-aux-native-retry-mutant-red.log`.
+
+## Remaining gates and limits
+
+This is local preparation. The dependency must merge, the normal merge onto its
+actual landed master must preserve this tested patch and all master changes, and
+fresh exact-head CI plus Greptile 5/5 must pass before protected merge. Prior
+review scores and checks on older heads are not current passes. The previous
+Snyk status was pending and is not called successful here. No shared OS resource
+cleanup, schema change, whole-repository local matrix, live provider execution,
+external alarm, physical suspension, or savings qualification is claimed.
diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md
index 2fa84783c00..b3a2804d83d 100644
--- a/maps/agent-access-map/agents.md
+++ b/maps/agent-access-map/agents.md
@@ -108,6 +108,23 @@ qualification remains not run.
## `instructions`
+The operator's Advanced YAML editor uses the existing configuration GET/PATCH
+operations plus the owning instructions, skills, tools, channels, permissions,
+secrets, API key, and revision APIs. It does not add aggregate write authority;
+each request retains its own company and actor checks. Resource reads fail closed.
+Observed inherited access/history is read-only. Multi-resource writes are
+sequential, with preflight comparison and explicit partial-failure recovery;
+Core configuration, managed files, and entry revisions enforce their existing
+atomic revision/hash preconditions. External supporting files and other
+unversioned resource writes retain a race window after preflight. Configuration reads include the complete redacted, revisioned fields
+and `configurationRevision`; writes accept an optional
+`expectedConfigurationRevision`, checked again under the write lock. A stale
+write returns `409`. Revision-guarded writes preserve unchanged redacted values
+at their original paths. Configuration read/configure authority, company scope,
+instruction-path restrictions, environment/account checks, revision history, and
+activity attribution still apply. YAML does not expose a permission-grant or
+lifecycle mutation shortcut. See [the format](../../doc/agent-configuration-yaml.md).
+
Capability ID: `agents/instructions`.
Review: `baseline`.
diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md
index 909e3d1e084..8b511c038fe 100644
--- a/maps/feature-map/agents.md
+++ b/maps/feature-map/agents.md
@@ -49,6 +49,21 @@ resource allocation waits for approval.
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
+Open **Advanced** at `/agents/:agentId/advanced` for the complete agent
+[YAML configuration](../../doc/agent-configuration-yaml.md). Edit a name, budget,
+runtime setting, and adapter key; save, reload, and check the ordinary settings
+and Revisions. Try malformed YAML, a missing/unknown field, a removed nested key,
+and a concurrent settings edit. Errors must retain the draft; stale saves return
+a conflict. Unchanged secret markers must preserve their stored values. Navigating
+away with a draft must show the existing unsaved-change guard. **Reload saved**
+discards the draft. Also edit instruction text, skill pins, direct tool installs,
+channel settings/resources, permissions/trust/project boundaries, secret operations,
+and API key operations. Verify each result in its owning tab. Revision restore is
+an isolated save. Inherited access/history under `observed` is read-only. An
+interrupted multi-resource save must report partial progress, preserve returned
+one-time keys, and require reload before another save. Domain APIs retain their
+existing authorization; a multi-resource save is not atomic.
+
For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator
with agent-management permission can select additional projects and explicitly
save them. Existing projects and other containment restrictions remain intact.
diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts
index 588b64556df..caba0b66d12 100644
--- a/packages/paperclip-runner/src/native-session-runtime.test.ts
+++ b/packages/paperclip-runner/src/native-session-runtime.test.ts
@@ -3499,10 +3499,6 @@ describe("executeNativeSession recovery", () => {
});
it("stops and closes a timed-out consumer even when the caller requested a warm session", async () => {
- let markAppendStarted = () => {};
- const appendStarted = new Promise((resolve) => {
- markAppendStarted = resolve;
- });
let releaseAppend = () => {};
const appendReleased = new Promise((resolve) => {
releaseAppend = resolve;
@@ -3515,7 +3511,6 @@ describe("executeNativeSession recovery", () => {
let appendCommitted = false;
const appendEvent = vi.fn(
async (_event: PrpEvent, options?: { signal: AbortSignal }) => {
- markAppendStarted();
await Promise.race([
appendReleased,
new Promise((_resolve, reject) => {
@@ -3612,28 +3607,39 @@ describe("executeNativeSession recovery", () => {
async completeRun() {},
};
- const execution = executeNativeSession({
- input,
- backend,
- controlPlane: port,
- runnerInstanceId: "runner-recovery",
- controlPlaneInstanceId: "control-recovery",
- timeoutMs: 1,
- keepSessionOpen: true,
- });
- const rejection = expect(execution).rejects.toThrow(
- "native session timed out",
- );
- await appendStarted;
- await vi.waitFor(() => expect(iteratorTeardown).toHaveBeenCalledOnce());
- await rejection;
- expect(cancel).toHaveBeenCalledOnce();
- expect(close).toHaveBeenCalled();
- expect(appendEvent).toHaveBeenCalledOnce();
- expect(appendCommitted).toBe(false);
- releaseAppend();
- await new Promise((resolve) => setImmediate(resolve));
- expect(appendCommitted).toBe(false);
+ // Reach the pending append before expiring the consumer. A real 1 ms
+ // deadline can expire during setup before the consumer reaches this case.
+ vi.useFakeTimers();
+ try {
+ const execution = executeNativeSession({
+ input,
+ backend,
+ controlPlane: port,
+ runnerInstanceId: "runner-recovery",
+ controlPlaneInstanceId: "control-recovery",
+ timeoutMs: 1,
+ keepSessionOpen: true,
+ });
+ const rejection = expect(execution).rejects.toThrow(
+ "native session timed out",
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ expect(appendEvent).toHaveBeenCalledOnce();
+ await vi.advanceTimersByTimeAsync(1);
+ await rejection;
+ expect(iteratorTeardown).toHaveBeenCalledOnce();
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(close).toHaveBeenCalled();
+ expect(appendEvent).toHaveBeenCalledOnce();
+ expect(appendCommitted).toBe(false);
+ releaseAppend();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(appendCommitted).toBe(false);
+ } finally {
+ releaseAppend();
+ releaseTeardown();
+ vi.useRealTimers();
+ }
});
it("closes a failed session while retaining an uncancellable event read", async () => {
diff --git a/packages/shared/src/agent-configuration-resources.ts b/packages/shared/src/agent-configuration-resources.ts
new file mode 100644
index 00000000000..bc407fcc18c
--- /dev/null
+++ b/packages/shared/src/agent-configuration-resources.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+import { updateAgentPermissionsSchema, createAgentKeySchema } from "./validators/agent.js";
+import { agentDesiredSkillSelectionSchema, agentDesiredSkillEntrySchema } from "./validators/adapter-skills.js";
+import { createToolProfileWithEntriesSchema, createToolProfileEntryForProfileSchema, createToolPolicySchema } from "./validators/tool-access.js";
+import { chatProviderSchema, updateChatEndpointSchema, replaceChatEndpointResourcesSchema, createChatEndpointSchema, configureChatEndpointSchema } from "./validators/chat-channels.js";
+import { createSecretSchema, rotateSecretSchema, updateSecretSchema } from "./validators/secret.js";
+
+const id = z.string().min(1);
+const filePath = z.string().min(1).max(512).refine(value => !value.startsWith("/") && !value.includes("\\") && !value.split("/").some(part => part === ".." || part === "." || !part), "Use a relative file path without traversal.");
+
+export const agentConfigurationResourceShape = {
+ permissions: updateAgentPermissionsSchema.extend({
+ canCreateSkills: z.boolean(), trustPreset: updateAgentPermissionsSchema.shape.trustPreset.unwrap(),
+ }).strict().optional(),
+ instructions: z.object({
+ mode: z.enum(["managed", "external"]).nullable(),
+ rootPath: z.string().nullable(),
+ entryFile: filePath,
+ files: z.record(filePath, z.string().max(1024 * 1024)),
+ }).strict().optional(),
+ skills: z.array(z.union([agentDesiredSkillSelectionSchema.options[0], agentDesiredSkillEntrySchema.strict()])).optional(),
+ tools: z.object({
+ connections: z.array(z.object({ connectionId: id, installed: z.boolean() }).strict()),
+ profiles: z.array(z.object({
+ profileId: id.nullable(), updateSharedDefinition: z.boolean().optional(), priority: z.number().int().min(0).max(10000),
+ metadata: z.record(z.string(), z.unknown()).nullable(),
+ configuration: createToolProfileWithEntriesSchema.extend({
+ entries: z.array(createToolProfileEntryForProfileSchema.strict()).max(250).optional(),
+ }).strict(),
+ }).strict()),
+ policies: z.array(z.object({ id: id.nullable(), configuration: createToolPolicySchema.strict() }).strict()),
+ }).strict().optional(),
+ channels: z.array(z.object({
+ id, provider: chatProviderSchema,
+ settings: updateChatEndpointSchema,
+ resources: z.array(replaceChatEndpointResourcesSchema.shape.resources.element.strict()),
+ }).strict()).optional(),
+ apiKeys: z.object({ create: z.array(createAgentKeySchema.strict()), revoke: z.array(id) }).strict().optional(),
+ revisions: z.object({ restore: id.nullable() }).strict().optional(),
+ secrets: z.object({
+ create: z.array(z.object({
+ secret: createSecretSchema.strict(),
+ bind: z.object({ env: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)), api: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)) }).strict(),
+ }).strict()),
+ rotate: z.array(z.object({ id, input: rotateSecretSchema.strict() }).strict()),
+ update: z.array(z.object({ id, input: updateSecretSchema.strict() }).strict()),
+ proposals: z.array(z.object({ id, decision: z.enum(["approve", "reject"]), reason: z.string().optional() }).strict()),
+ }).strict().optional(),
+ channelOperations: z.object({
+ create: z.array(z.object(createChatEndpointSchema.shape).omit({ assignedAgentId: true }).strict().refine(
+ value => (!value.slackApp && !value.slackSetupMethod) || value.provider === "slack",
+ { message: "Slack app details only apply to Slack connections", path: ["slackApp"] },
+ )),
+ setup: z.array(z.object({ id, input: configureChatEndpointSchema }).strict()),
+ revokeIdentityLinks: z.array(z.object({ endpointId: id, principalId: id }).strict()),
+ }).strict().optional(),
+};
+
+export type AgentConfigurationResources = z.infer>;
diff --git a/packages/shared/src/agent-configuration.ts b/packages/shared/src/agent-configuration.ts
new file mode 100644
index 00000000000..fad56b27a9a
--- /dev/null
+++ b/packages/shared/src/agent-configuration.ts
@@ -0,0 +1,62 @@
+import { z } from "zod";
+import { resolveAgentAppearance } from "./agent-appearance.js";
+import type { Agent } from "./types/agent.js";
+import { createAgentSchema } from "./validators/agent.js";
+import { objectWithoutDefaults } from "./validators/partial.js";
+import { agentConfigurationResourceShape } from "./agent-configuration-resources.js";
+
+export const agentConfigurationSpecSchema = objectWithoutDefaults(createAgentSchema.pick({
+ name: true,
+ role: true,
+ title: true,
+ icon: true,
+ appearance: true,
+ reportsTo: true,
+ capabilities: true,
+ adapterType: true,
+ adapterConfig: true,
+ runtimeConfig: true,
+ defaultEnvironmentId: true,
+ budgetMonthlyCents: true,
+ metadata: true,
+})).required().strict();
+
+export const agentConfigurationDocumentSchema = z.object({
+ apiVersion: z.literal("paperclip/v1"),
+ kind: z.literal("Agent"),
+ spec: agentConfigurationSpecSchema.extend(agentConfigurationResourceShape),
+ observed: z.record(z.string(), z.unknown()).optional(),
+}).strict();
+
+export type AgentConfigurationSpec = z.infer;
+export type AgentConfigurationDocument = z.infer;
+export type AgentConfigurationSource = Omit, "role" | "adapterType"> & {
+ role: string;
+ adapterType: string;
+};
+export type AgentConfigurationResponse = AgentConfigurationSource & { configurationRevision: string };
+
+export function agentConfigurationSpec(agent: AgentConfigurationSource) {
+ return {
+ name: agent.name,
+ role: agent.role,
+ title: agent.title,
+ icon: agent.icon,
+ appearance: resolveAgentAppearance(agent.appearance, agent.id),
+ reportsTo: agent.reportsTo,
+ capabilities: agent.capabilities,
+ adapterType: agent.adapterType,
+ adapterConfig: agent.adapterConfig,
+ runtimeConfig: agent.runtimeConfig,
+ defaultEnvironmentId: agent.defaultEnvironmentId ?? null,
+ budgetMonthlyCents: agent.budgetMonthlyCents,
+ metadata: agent.metadata,
+ };
+}
+
+export function canonicalAgentConfiguration(value: unknown): string {
+ return JSON.stringify(value, (_key, item: unknown) => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
+ return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
+ });
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index a0a447a9b82..fb7f6b2e31a 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -2861,5 +2861,5 @@ export * from "./prompt-injection-prevention.js";
export * from "./workspace-provider.js";
export * from "./workflows.js";
-
export { updateWorkspacePreferenceSchema, type UpdateWorkspacePreference, type WorkspacePreference } from "./workspace-preferences.js";
+export * from "./agent-configuration.js";
diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts
index 1e9e42e2876..4801092351f 100644
--- a/packages/shared/src/validators/agent.ts
+++ b/packages/shared/src/validators/agent.ts
@@ -174,6 +174,7 @@ export const updateAgentSchema = objectWithoutDefaults(
permissions: z.never().optional(),
runtimeSettings: agentRuntimeSettingsPatchSchema.optional(),
replaceAdapterConfig: z.boolean().optional(),
+ expectedConfigurationRevision: z.string().regex(/^[a-f0-9]{64}$/).optional(),
status: z.enum(["paused", "idle", "terminated"]).optional(),
spentMonthlyCents: z.never().optional(),
});
diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts
index 22515f7dc9c..4f0bc522188 100644
--- a/server/src/__tests__/agent-adapter-validation-routes.test.ts
+++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts
@@ -334,6 +334,53 @@ describe("agent routes adapter validation", () => {
await unregisterTestAdapter(missingAdapterType);
});
+ it("round-trips the protected YAML projection and forwards its revision to the write lock", async () => {
+ const existing = {
+ ...(await mockAgentService.getById()),
+ adapterConfig: { model: "old", removeMe: true, env: { DEMO_VALUE: "private-fixture" } },
+ metadata: { token: "private-metadata-fixture" },
+ };
+ mockAgentService.getById.mockResolvedValue(existing);
+ const app = await createApp();
+ const loaded = await requestApp(app, base => request(base).get(`/api/agents/${existing.id}/configuration`));
+ expect(loaded.status).toBe(200);
+ expect(loaded.body.configurationRevision).toMatch(/^[a-f0-9]{64}$/);
+ expect(JSON.stringify(loaded.body)).not.toContain("private-fixture");
+ expect(JSON.stringify(loaded.body)).not.toContain("private-metadata-fixture");
+ const adapterConfig = { ...loaded.body.adapterConfig, model: "new" };
+ delete adapterConfig.removeMe;
+ const saved = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
+ adapterConfig, replaceAdapterConfig: true, metadata: { ...loaded.body.metadata, label: "new" },
+ expectedConfigurationRevision: loaded.body.configurationRevision,
+ }));
+ expect(saved.status, JSON.stringify(saved.body)).toBe(200);
+ expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
+ adapterConfig: expect.objectContaining({ model: "new", env: existing.adapterConfig.env }),
+ metadata: { ...existing.metadata, label: "new" },
+ }), expect.objectContaining({ expectedConfigurationRevision: loaded.body.configurationRevision }));
+ expect(mockAgentService.update.mock.calls[0][1].adapterConfig).not.toHaveProperty("removeMe");
+ expect(saved.body.configurationRevision).not.toBe(loaded.body.configurationRevision);
+ expect(JSON.stringify(saved.body)).not.toContain("private-fixture");
+ expect(JSON.stringify(saved.body)).not.toContain("private-metadata-fixture");
+ expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated" }));
+ });
+
+ it("rejects stale and unauthorized YAML saves before calling the update service", async () => {
+ const existing = await mockAgentService.getById();
+ const app = await createApp();
+ const loaded = await requestApp(app, base => request(base).get(`/api/agents/${existing.id}/configuration`));
+ const stale = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
+ title: "Stale", expectedConfigurationRevision: "0".repeat(64),
+ }));
+ expect(stale.status).toBe(409);
+ mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_missing_grant", explanation: "No configuration permission" });
+ const denied = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
+ title: "Denied", expectedConfigurationRevision: loaded.body.configurationRevision,
+ }));
+ expect(denied.status).toBe(403);
+ expect(mockAgentService.update).not.toHaveBeenCalled();
+ });
+
it("routes narrow roster edits through authorization, config merging and audit history", async () => {
const existing = { ...(await mockAgentService.getById()), adapterConfig: { model: "old", cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 } } };
mockAgentService.getById.mockResolvedValue(existing);
diff --git a/server/src/__tests__/agent-configuration-revision.test.ts b/server/src/__tests__/agent-configuration-revision.test.ts
new file mode 100644
index 00000000000..2eacda7c437
--- /dev/null
+++ b/server/src/__tests__/agent-configuration-revision.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { resolveAgentAppearance } from "@paperclipai/shared";
+import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js";
+import { redactAgentAdapterConfig } from "../redaction.js";
+import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
+
+const agent = {
+ id: "11111111-1111-4111-8111-111111111111", name: "Codie", role: "engineer" as const, title: null,
+ icon: null, appearance: null, reportsTo: null, capabilities: null, adapterType: "codex_local" as const,
+ adapterConfig: { model: "example", env: { API_KEY: "fake-secret" }, apiKey: "fake-adapter-secret" },
+ runtimeConfig: {}, defaultEnvironmentId: null, budgetMonthlyCents: 0, metadata: null, permissions: {},
+};
+
+describe("agent configuration revisions and redactions", () => {
+ afterEach(() => vi.unstubAllEnvs());
+
+ it("keys revisions with the private instance secret while detecting hidden credential edits", () => {
+ vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "a".repeat(32));
+ const original = agentConfigurationRevision(agent);
+ expect(agentConfigurationRevision({ ...agent, adapterConfig: { ...agent.adapterConfig, apiKey: "changed-hidden-secret" } }))
+ .not.toBe(original);
+ vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "b".repeat(32));
+ expect(agentConfigurationRevision(agent)).not.toBe(original);
+ vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "a".repeat(32));
+ expect(agentConfigurationRevision(agent)).toBe(original);
+ });
+
+ it("uses a signing key rotated in place without restarting the process", () => {
+ const home = mkdtempSync(path.join(os.tmpdir(), "agent-yaml-signing-"));
+ vi.stubEnv("PAPERCLIP_HOME", home);
+ vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "");
+ try {
+ const original = agentConfigurationRevision(agent);
+ const keyPath = path.join(path.dirname(resolveDefaultSecretsKeyFilePath()), "decision-signing.key");
+ const previous = readFileSync(keyPath, "utf8");
+ writeFileSync(keyPath, "rotated-private-key".repeat(4), { mode: 0o600 });
+ expect(agentConfigurationRevision(agent)).not.toBe(original);
+ writeFileSync(keyPath, previous, { mode: 0o600 });
+ expect(agentConfigurationRevision(agent)).toBe(original);
+ } finally { rmSync(home, { recursive: true, force: true }); }
+ });
+
+ it("normalizes raw and presented rows and ignores live bookkeeping", () => {
+ expect(agentConfigurationRevision({ ...agent, appearance: resolveAgentAppearance(null, agent.id), permissions: { canCreateAgents: false, canCreateSkills: true } }))
+ .toBe(agentConfigurationRevision(agent));
+ const runtimeChange = { ...agent, status: "running", spentMonthlyCents: 100, updatedAt: new Date() };
+ expect(agentConfigurationRevision(runtimeChange)).toBe(agentConfigurationRevision(agent));
+ for (const patch of [{ name: "Renamed" }, { budgetMonthlyCents: 2 }, { adapterConfig: { ...agent.adapterConfig, model: "new" } }]) {
+ expect(agentConfigurationRevision({ ...agent, ...patch })).not.toBe(agentConfigurationRevision(agent));
+ }
+ });
+
+ it("preserves redacted secrets while changing another key and permits explicit removal", () => {
+ const displayed = redactAgentAdapterConfig(agent.adapterConfig);
+ expect(restoreAgentConfigurationRedactions({ ...displayed, model: "new" }, agent.adapterConfig, displayed))
+ .toEqual({ ...agent.adapterConfig, model: "new" });
+ expect(restoreAgentConfigurationRedactions({ model: "new" }, agent.adapterConfig, displayed)).toEqual({ model: "new" });
+ });
+
+ it("rejects moved and partially edited redactions", () => {
+ expect(() => restoreAgentConfigurationRedactions({ other: "***REDACTED***" }, { apiKey: "secret" }, { apiKey: "***REDACTED***" })).toThrow("redacted value");
+ expect(() => restoreAgentConfigurationRedactions("Bearer ***REDACTED*** edited", "Bearer secret", "Bearer ***REDACTED***")).toThrow("redacted value");
+ });
+});
diff --git a/server/src/lib/agent-configuration-revision.ts b/server/src/lib/agent-configuration-revision.ts
new file mode 100644
index 00000000000..89ebd49c64b
--- /dev/null
+++ b/server/src/lib/agent-configuration-revision.ts
@@ -0,0 +1,32 @@
+import { createHmac } from "node:crypto";
+import { agentConfigurationSpec, canonicalAgentConfiguration, type AgentConfigurationSource } from "@paperclipai/shared";
+import { normalizeAgentPermissions } from "./agent-permissions.js";
+import { REDACTED_EVENT_VALUE } from "../redaction.js";
+import { unprocessable } from "../errors.js";
+import { resolveDecisionSigningSecret } from "../services/decision-signing.js";
+
+export function agentConfigurationRevision(agent: AgentConfigurationSource & { permissions: Record }) {
+ return createHmac("sha256", resolveDecisionSigningSecret()).update("agent-configuration-v1:").update(canonicalAgentConfiguration({
+ id: agent.id, ...agentConfigurationSpec(agent), permissions: normalizeAgentPermissions(agent.permissions),
+ })).digest("hex");
+}
+
+export function restoreAgentConfigurationRedactions(requested: unknown, saved: unknown, displayed: unknown): unknown {
+ if (canonicalAgentConfiguration(requested) === canonicalAgentConfiguration(displayed)) return saved;
+ if (typeof requested === "string" && requested.includes(REDACTED_EVENT_VALUE)) {
+ throw unprocessable("A redacted value was changed or moved. Keep it unchanged, remove it, or replace the entire value.");
+ }
+ if (Array.isArray(requested)) {
+ return requested.map((value, index) => restoreAgentConfigurationRedactions(value,
+ Array.isArray(saved) ? saved[index] : undefined, Array.isArray(displayed) ? displayed[index] : undefined));
+ }
+ if (requested && typeof requested === "object") {
+ const previous = saved && typeof saved === "object" ? saved as Record : {};
+ const redacted = displayed && typeof displayed === "object" ? displayed as Record : {};
+ return Object.fromEntries(Object.entries(requested).map(([key, value]) => [key,
+ restoreAgentConfigurationRedactions(value, Object.hasOwn(previous, key) ? previous[key] : undefined,
+ Object.hasOwn(redacted, key) ? redacted[key] : undefined),
+ ]));
+ }
+ return requested;
+}
diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts
index 25c0ed7c9f5..c28eb81aa18 100644
--- a/server/src/lib/agent-records.ts
+++ b/server/src/lib/agent-records.ts
@@ -74,6 +74,7 @@ export interface ClaudeLoginContext {
}
export interface UpdateAgentOptions {
+ expectedConfigurationRevision?: string;
expectedConfiguration?: Pick;
recordRevision?: RevisionMetadata;
allowBuiltInAgentMetadata?: boolean;
diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
index 8f494cc6fa7..f6c92607fc5 100644
--- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
+++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
@@ -16,7 +16,7 @@ import { errorHandler } from "../../../middleware/error-handler.js";
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
-import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db";
+import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, applyPendingMigrations, closeRegisteredClients, createDb, type Db } from "@paperclipai/db";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { createAgentLifecycle, configureAgentLifecycle, startAgentLifecycle } from "../../../services/agent-lifecycle.js";
@@ -25,17 +25,27 @@ import { createLifecycleStore as createStore } from "../adapters/postgres.js";
import { transition } from "../domain/policy.js";
import type { LifecycleAgent } from "../application/ports.js";
import { budgetService } from "../../../services/budgets.js";
+import { agentConfigurationRevision } from "../../../lib/agent-configuration-revision.js";
+import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
-const support = await getEmbeddedPostgresTestSupport();
+const external = resolveExternalTestDatabaseUrl("PAPERCLIP_AGENT_LIFECYCLE_TEST_DATABASE_URL");
+const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("agent lifecycle commands", () => {
let database: Awaited>;
let db: Db;
let companyId: string;
const workers: ReturnType[] = [];
- beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }, 90_000);
- afterAll(async () => { for (const worker of workers) await worker.stop(); await database?.cleanup(); });
+ beforeAll(async () => {
+ if (external) { await applyPendingMigrations(external); db = createDb(external); }
+ else { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }
+ }, 90_000);
+ afterAll(async () => {
+ for (const worker of workers) await worker.stop();
+ if (external) await closeRegisteredClients(external);
+ else await database?.cleanup();
+ });
beforeEach(async () => {
for (const work of workers) await work.stop();
companyId = randomUUID();
@@ -75,6 +85,53 @@ const support = await getEmbeddedPostgresTestSupport();
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
});
+ it("fences YAML saves under the write lock without treating runtime bookkeeping as an edit", async () => {
+ const agent = await hire();
+ const revision = agentConfigurationRevision(await current(agent.id));
+ await db.update(agents).set({ lastHeartbeatAt: new Date(), updatedAt: new Date() }).where(eq(agents.id, agent.id));
+ await createAgentLifecycle(db).updateConfiguration(agent.id, { title: "New title" }, { expectedConfigurationRevision: revision });
+ await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { name: "Stale overwrite" }, { expectedConfigurationRevision: revision }))
+ .rejects.toThrow("configuration changed while you were editing");
+ expect(await current(agent.id)).toMatchObject({ name: agent.name, title: "New title" });
+ });
+
+ it("saves YAML configuration through the normal route with redaction, revisions, and access checks", async () => {
+ const [agent] = await db.insert(agents).values({ companyId, name: "YAML test", adapterType: "process",
+ adapterConfig: { command: "echo", removeMe: true, env: { DEMO_VALUE: "private-fixture" }, apiKey: "private-api-fixture" },
+ runtimeConfig: { heartbeat: { enabled: false } }, metadata: { token: "private-metadata-fixture" },
+ }).returning();
+ function app(actor: Express.Request["actor"]) {
+ const app = express(); app.use(express.json());
+ app.use((req, _res, next) => { req.actor = actor; next(); });
+ app.use("/api", agentRoutes(db)); app.use(errorHandler); return app;
+ }
+ const board = app({ type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] });
+ const response = await request(board).get(`/api/agents/${agent.id}/configuration`).expect(200);
+ expect(response.body).toMatchObject({ capabilities: null, budgetMonthlyCents: 0, defaultEnvironmentId: null });
+ expect(JSON.stringify(response.body)).not.toContain("private-fixture");
+ expect(JSON.stringify(response.body)).not.toContain("private-api-fixture");
+ expect(JSON.stringify(response.body)).not.toContain("private-metadata-fixture");
+ const adapterConfig = { ...response.body.adapterConfig, command: "printf" };
+ delete adapterConfig.removeMe;
+ const saved = await request(board).patch(`/api/agents/${agent.id}`).send({
+ adapterConfig, replaceAdapterConfig: true, title: "Updated using YAML",
+ expectedConfigurationRevision: response.body.configurationRevision,
+ });
+ expect(saved.status, JSON.stringify(saved.body)).toBe(200);
+ expect(saved.body.configurationRevision).not.toBe(response.body.configurationRevision);
+ const persisted = await current(agent.id);
+ expect(persisted.adapterConfig).toMatchObject({ command: "printf", env: { DEMO_VALUE: { type: "plain", value: "private-fixture" } }, apiKey: "private-api-fixture" });
+ expect(persisted.adapterConfig).not.toHaveProperty("removeMe");
+ expect(persisted.runtimeConfig).toEqual(agent.runtimeConfig);
+ expect(persisted.metadata).toEqual(agent.metadata);
+ expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(1);
+ expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.updated")))).toHaveLength(1);
+ await request(board).patch(`/api/agents/${agent.id}`).send({ name: "Stale", expectedConfigurationRevision: response.body.configurationRevision }).expect(409);
+ const foreign = app({ type: "board", source: "session", userId: "outsider", companyIds: [], memberships: [] });
+ await request(foreign).get(`/api/agents/${agent.id}/configuration`).expect(403);
+ await request(foreign).patch(`/api/agents/${agent.id}`).send({ title: "Denied", expectedConfigurationRevision: saved.body.configurationRevision }).expect(404);
+ });
+
it("maps existing states without starting setup or clearing pause reasons", async () => {
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts
index 011508a0d78..7359ddfacf1 100644
--- a/server/src/modules/agent-lifecycle/adapters/configuration.ts
+++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts
@@ -1,5 +1,6 @@
import { isDeepStrictEqual } from "node:util";
import { conflict } from "../../../errors.js";
+import { agentConfigurationRevision } from "../../../lib/agent-configuration-revision.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { agents, type Db } from "@paperclipai/db";
@@ -13,6 +14,9 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
assertConfigurationFields(data);
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
if (!current) return null;
+ if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) {
+ throw conflict("Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again.");
+ }
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index d81f90d21cb..5e066f8f0ba 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -1,4 +1,6 @@
import { latestFailedRunsByAgent } from "@paperclipai/shared";
+import { agentConfigurationSpec } from "@paperclipai/shared";
+import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js";
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
@@ -2943,6 +2945,9 @@ export function agentRoutes(
function redactAgentConfiguration(agent: Awaited>) {
if (!agent) return null;
return {
+ ...agentConfigurationSpec(agent),
+ configurationRevision: agentConfigurationRevision(agent),
+ metadata: redactEventPayload(agent.metadata),
id: agent.id,
companyId: agent.companyId,
name: agent.name,
@@ -5399,6 +5404,20 @@ export function agentRoutes(
: { ...(req.body as Record) };
const runner = patchData.runner as AgentRunnerChoice | undefined;
delete patchData.runner;
+ const expectedConfigurationRevision = patchData.expectedConfigurationRevision as string | undefined;
+ delete patchData.expectedConfigurationRevision;
+ if (expectedConfigurationRevision && expectedConfigurationRevision !== agentConfigurationRevision(existing)) {
+ throw conflict("Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again.");
+ }
+ if (expectedConfigurationRevision) {
+ await assertCanConfigureAgentSetup(req, existing);
+ const displayed = redactAgentConfiguration(existing)!;
+ for (const key of ["adapterConfig", "runtimeConfig", "metadata"] as const) {
+ if (hasOwn(patchData, key)) {
+ patchData[key] = restoreAgentConfigurationRedactions(patchData[key], existing[key], displayed[key]);
+ }
+ }
+ }
const requestedStatus = patchData.status;
delete patchData.status;
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
@@ -5651,6 +5670,7 @@ export function agentRoutes(
const actor = getActorInfo(req);
const updateOptions = {
+ expectedConfigurationRevision,
...(runtimeSettings ? { expectedConfiguration: {
adapterType: existing.adapterType, adapterConfig: existing.adapterConfig,
runtimeConfig: existing.runtimeConfig, defaultEnvironmentId: existing.defaultEnvironmentId,
@@ -5690,7 +5710,7 @@ export function agentRoutes(
details: summarizeAgentUpdateDetails(patchData),
});
- res.json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
+ res.json({ ...redactAgentRowForResponse(agent), ...redactAgentConfiguration(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
});
router.post("/agents/:id/lifecycle/retry", async (req, res) => {
diff --git a/server/src/services/heartbeat/__tests__/run-control-fixtures.ts b/server/src/services/heartbeat/__tests__/run-control-fixtures.ts
index 2adce9077c1..06ed71c35d1 100644
--- a/server/src/services/heartbeat/__tests__/run-control-fixtures.ts
+++ b/server/src/services/heartbeat/__tests__/run-control-fixtures.ts
@@ -1,8 +1,9 @@
import type { Db } from "@paperclipai/db";
-import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns } from "@paperclipai/db";
+import { agents, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns } from "@paperclipai/db";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
+import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
import { runningProcesses } from "../../../adapters/index.js";
import { terminateLocalService } from "../../local-service-supervisor.js";
import { createHeartbeatRunState } from "../run-state.js";
@@ -65,15 +66,17 @@ export function callbacks(db: Db): TestCallbacks {
failedProcessRunCancellations: new Map; failed: boolean; error?: unknown }>(),
} satisfies HeartbeatRunControlDependencies;
}
-export const runControlPostgresSupport = await getEmbeddedPostgresTestSupport();
+const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_RUN_CONTROL_TEST_DATABASE_URL");
+export const runControlPostgresSupport = externalTestDatabaseUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
if (!runControlPostgresSupport.supported) console.warn(`Skipping run-control database tests: ${runControlPostgresSupport.reason}`);
export function setupRunControlDatabase(name: string, ready: (db: Db) => void) {
let database: Awaited>;
let db: Db;
beforeAll(async () => {
- database = await startEmbeddedPostgresTestDatabase(name);
- db = createDb(database.connectionString);
+ if (externalTestDatabaseUrl) await applyPendingMigrations(externalTestDatabaseUrl);
+ else database = await startEmbeddedPostgresTestDatabase(name);
+ db = createDb(externalTestDatabaseUrl ?? database!.connectionString);
ready(db);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
diff --git a/server/src/services/heartbeat/run-control.test.ts b/server/src/services/heartbeat/run-control.test.ts
index ab0a9fbb5a8..c73dba4d120 100644
--- a/server/src/services/heartbeat/run-control.test.ts
+++ b/server/src/services/heartbeat/run-control.test.ts
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
-import { eq, inArray } from "drizzle-orm";
+import { eq, inArray, sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runningProcesses } from "../../adapters/index.js";
import {
@@ -14,6 +14,7 @@ import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
import { HttpError } from "../../errors.js";
import { captureException } from "../../sentry.js";
import { terminateLocalService } from "../local-service-supervisor.js";
+import { publishLiveEvent } from "../live-events.js";
import { createHeartbeatRunState } from "./run-state.js";
import { createHeartbeatRunControl } from "./run-control.js";
import { preserveCancelledNativeWorkspaces } from "../native-runtime/native-failed-workspace-recovery.js";
@@ -173,6 +174,79 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
)).toMatchObject({ status: "cancelled" });
});
+ it.each([["40P01", 3], ["XX000", 1]] as const)("bounds cancellation transaction attempts for PostgreSQL %s", async (code, attempts) => {
+ const { company, agent } = await fixture();
+ const run = await liveRun(company.id, agent.id);
+ const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Cancellation failure", status: "in_progress",
+ assigneeAgentId: agent.id, executionRunId: run.id }).returning();
+ await db.update(heartbeatRuns).set({ issueId: issue.id, contextSnapshot: { issueId: issue.id } }).where(eq(heartbeatRuns.id, run.id));
+ await db.execute(sql`create sequence run_control_cancel_attempts`);
+ await db.execute(sql.raw(`create function run_control_cancel_fault() returns trigger language plpgsql as $$
+ begin
+ if new.result_json->'executionCancellation'->>'state' = 'requested' then
+ perform nextval('run_control_cancel_attempts');
+ raise exception using errcode = '${code}', message = 'Synthetic cancellation transaction failure';
+ end if;
+ return new;
+ end $$`));
+ await db.execute(sql`create trigger run_control_cancel_fault before update on heartbeat_runs
+ for each row execute function run_control_cancel_fault()`);
+ const deps = callbacks(db);
+ try {
+ await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Operator Stop", {
+ resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
+ })).rejects.toMatchObject({ cause: { code } });
+ const [counter] = await db.execute<{ count: number }>(sql`select last_value::int as count from run_control_cancel_attempts`);
+ expect(counter.count).toBe(attempts);
+ expect(await deps.getRun(run.id)).toMatchObject({ status: "running", resultJson: null });
+ expect(terminateLocalService).not.toHaveBeenCalled();
+ expect(deps.appendRunEvent).not.toHaveBeenCalled();
+ } finally {
+ await db.execute(sql`drop trigger run_control_cancel_fault on heartbeat_runs`);
+ await db.execute(sql`drop function run_control_cancel_fault()`);
+ await db.execute(sql`drop sequence run_control_cancel_attempts`);
+ }
+ });
+
+ it("finishes operator Stop after a concurrent run writer causes a transaction deadlock", async () => {
+ const { company, agent } = await fixture();
+ const run = await liveRun(company.id, agent.id);
+ const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Stop contention", status: "in_progress",
+ assigneeAgentId: agent.id, executionRunId: run.id, checkoutRunId: run.id }).returning();
+ await db.update(heartbeatRuns).set({ issueId: issue.id, contextSnapshot: { issueId: issue.id } }).where(eq(heartbeatRuns.id, run.id));
+ let enter!: () => void, release!: () => void;
+ const entered = new Promise(resolve => { enter = resolve; });
+ const released = new Promise(resolve => { release = resolve; });
+ let blockerPid = 0;
+ const writer = db.transaction(async tx => {
+ // Make Stop's default detector see the cycle first, so this reproduces
+ // the API-side victim observed in CI rather than a random victim.
+ await tx.execute(sql`set local deadlock_timeout = '10s'`);
+ const [backend] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
+ blockerPid = backend.pid;
+ await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)).for("update");
+ enter(); await released;
+ await tx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, issue.id));
+ });
+ await entered;
+ const deps = callbacks(db);
+ const stopping = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Operator Stop", {
+ resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
+ }).then(value => ({ value, error: undefined }), error => ({ value: undefined, error }));
+ try {
+ await vi.waitFor(async () => {
+ const waiting = await db.execute(sql`select pid from pg_stat_activity
+ where ${blockerPid} = any(pg_blocking_pids(pid)) and wait_event_type = 'Lock'`);
+ expect(waiting.length).toBeGreaterThan(0);
+ }, { timeout: 5000 });
+ } finally { release(); }
+ await writer;
+ const result = await stopping;
+ expect(result.error).toBeUndefined();
+ expect(result.value).toMatchObject({ status: "cancelled" });
+ expect(await deps.getRun(run.id)).toMatchObject({ status: "cancelled" });
+ });
+
it.each(["queued", "running"] as const)("preserves case intent when a guarded Stop loses to %s promotion", async status => {
const { company, agent } = await fixture();
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Promoted case work", status: "todo", assigneeAgentId: agent.id }).returning();
@@ -393,6 +467,63 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
.toMatchObject({ status: "in_progress", phase: "verification", completedAt: null });
});
+ it.each([false, true])("discards rolled-back native Stop claims and activity before retry (authority revoked=%s)", async revokeAuthority => {
+ const f = await boardTerminalFixture("running", "native");
+ const requestId = randomUUID(), deps = callbacks(db);
+ vi.mocked(publishLiveEvent).mockClear();
+ const transact = db.transaction.bind(db);
+ let injected = false;
+ const transaction = vi.spyOn(db, "transaction").mockImplementationOnce(async (operation, config) => {
+ try {
+ return await transact(async tx => {
+ const result = await operation(tx);
+ const [claimed] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id));
+ expect(claimed.resultJson?.startupCancellation).toMatchObject({ cancellationRequestId: requestId });
+ expect(claimed.resultJson?.executionCancellation).toMatchObject({ state: "requested" });
+ expect((await tx.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0].state).toBe("cancelled");
+ expect((await tx.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)))
+ .filter(row => row.action === "case.handoff_cancelled")).toHaveLength(1);
+ expect(deps.getAgent).not.toHaveBeenCalled();
+ injected = true;
+ await tx.execute(sql`do $$ begin raise exception using errcode = '40P01', message = 'Deadlock after native Stop claim'; end $$`);
+ return result;
+ }, config);
+ } catch (error) {
+ if (injected && revokeAuthority) await db.update(nativeRunFinalizations)
+ .set({ phase: "terminal_failure", failureCode: "retry_authority_revoked", nextAttemptAt: null })
+ .where(eq(nativeRunFinalizations.runId, f.run.id));
+ throw error;
+ }
+ });
+ try {
+ const stopping = createHeartbeatRunControl(db, deps).cancelRunInternal(f.run.id, "Retry native Stop", {
+ resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
+ cancellationRequestId: requestId, cancellationRequestedByUserId: "operator",
+ });
+ if (revokeAuthority) {
+ await expect(stopping).rejects.toMatchObject({ status: 409 });
+ expect(await deps.getRun(f.run.id)).toMatchObject({ status: "failed", resultJson: null });
+ expect(deps.getAgent).not.toHaveBeenCalled();
+ expect(deps.options.closeWarmNativeSessionsForRun).not.toHaveBeenCalled();
+ expect(deps.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
+ } else {
+ await expect(stopping).resolves.toMatchObject({ status: "cancelled" });
+ expect((await deps.getRun(f.run.id))?.resultJson?.startupCancellation).toMatchObject({ cancellationRequestId: requestId });
+ }
+ expect(injected).toBe(true);
+ expect(transaction.mock.calls.length).toBeGreaterThanOrEqual(2);
+ expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0].state)
+ .toBe(revokeAuthority ? "awaiting_completion" : "cancelled");
+ const expected = revokeAuthority ? 0 : 1;
+ expect((await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)))
+ .filter(row => row.action === "case.handoff_cancelled")).toHaveLength(expected);
+ expect(vi.mocked(publishLiveEvent).mock.calls.map(([event]) => event)
+ .filter(event => event.companyId === f.company.id && event.type === "activity.logged"
+ && event.payload?.action === "case.handoff_cancelled")).toHaveLength(expected);
+ expect(terminateLocalService).not.toHaveBeenCalled();
+ } finally { transaction.mockRestore(); }
+ });
+
it("stops current preparing execution despite a prior terminal checkout when cancelling board intent", async () => {
// Queue admission binds the new execution before preparation adopts its
// predecessor's checkout. The execution owner is authoritative in this gap.
diff --git a/server/src/services/heartbeat/run-control.ts b/server/src/services/heartbeat/run-control.ts
index c1b50a46517..c32cebc2013 100644
--- a/server/src/services/heartbeat/run-control.ts
+++ b/server/src/services/heartbeat/run-control.ts
@@ -1134,7 +1134,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
let caseHandoffRetryClaimed = false;
if (handoffIssueId && supersedesCaseHandoff && options.resultJson?.caseHandoffYieldStop !== true) {
const publications: ActivityPublication[] = [];
- const supersessionApplied = await db.transaction(async tx => {
+ const applySupersession = () => db.transaction(async tx => {
const [issue] = await tx.select({ id: issues.id, executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId,
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
.where(and(eq(issues.companyId, run!.companyId), eq(issues.id, handoffIssueId))).for("update");
@@ -1189,7 +1189,27 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
await cancelCaseHandoffsForIssue(tx as unknown as Db, { companyId: run!.companyId, issueId: handoffIssueId,
sourceRunId: runId, includeUnboundTerminalIntent: Boolean(fenced && ownsTask), reason, publications });
return true;
- }).catch(rethrowNativeCancellationLockConflict);
+ });
+ let supersessionApplied = false;
+ for (let attempt = 0; ; attempt++) {
+ const previousRun = run;
+ try {
+ supersessionApplied = await applySupersession();
+ break;
+ } catch (error) {
+ const value = parseObject(error);
+ const deadlock = value.code === "40P01" || parseObject(value.cause).code === "40P01";
+ if (!deadlock || attempt >= 2) rethrowNativeCancellationLockConflict(error);
+ // PostgreSQL aborted the entire transaction. Discard its local
+ // receipts before re-reading current authority under fresh locks.
+ // Provider Stop and publication happen only after this boundary.
+ run = previousRun;
+ cancellationClaimed = false;
+ caseHandoffRetryClaimed = false;
+ publications.length = 0;
+ await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
+ }
+ }
publications.forEach(publishActivity);
if (!supersessionApplied) return null;
}
diff --git a/tests/e2e/agent-configuration.spec.ts b/tests/e2e/agent-configuration.spec.ts
new file mode 100644
index 00000000000..d09b17dc864
--- /dev/null
+++ b/tests/e2e/agent-configuration.spec.ts
@@ -0,0 +1,241 @@
+import { mkdtemp, readFile, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { expect, test as base, type APIRequestContext, type APIResponse, type Page } from "@playwright/test";
+import { parse, stringify } from "yaml";
+import type { AgentConfigurationDocument } from "@paperclipai/shared";
+import { startReviewProvider } from "../fixtures/connection-review-provider";
+
+const test = base.extend<{ toolProvider: Awaited>; settings: void }>({
+ settings: [async ({ request }, use) => {
+ const previous = await json(await request.get("/api/instance/settings/experimental"));
+ try {
+ await json(await request.patch("/api/instance/settings/experimental", { data: { enableBetaSkills: true, enableChatConnectors: true } }));
+ await use();
+ } finally {
+ await json(await request.patch("/api/instance/settings/experimental", { data: {
+ enableBetaSkills: previous.enableBetaSkills, enableChatConnectors: previous.enableChatConnectors,
+ } }));
+ }
+ }, { auto: true }],
+ toolProvider: async ({}, use) => {
+ const provider = await startReviewProvider();
+ try { await use(provider); }
+ finally { await provider.close(); }
+ },
+});
+
+test.use({ permissions: ["clipboard-read", "clipboard-write"] });
+
+async function json(response: APIResponse) {
+ expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
+ return response.json();
+}
+
+async function setup(request: APIRequestContext) {
+ const company = await json(await request.post("/api/companies", { data: { name: `Agent YAML ${Date.now()}` } }));
+ const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
+ name: "YAML engineer", role: "engineer", adapterType: "process", adapterConfig: { command: "true", removable: "old value" },
+ runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } },
+ } }));
+ return { company, agent, url: `/${company.issuePrefix}/agents/${agent.urlKey}` };
+}
+
+const editor = (page: Page) => page.getByRole("textbox", { name: "Agent YAML configuration" });
+async function document(page: Page): Promise {
+ await expect(editor(page)).toBeVisible();
+ await expect(editor(page)).toHaveAttribute("contenteditable", "true");
+ await editor(page).press("ControlOrMeta+a");
+ await editor(page).press("ControlOrMeta+c");
+ const source = await page.evaluate(() => navigator.clipboard.readText());
+ expect(source).toContain("apiVersion: paperclip/v1");
+ return parse(source);
+}
+async function edit(page: Page, change: (value: AgentConfigurationDocument) => void) {
+ const value = await document(page);
+ change(value);
+ await page.evaluate(source => navigator.clipboard.writeText(source), stringify(value, { lineWidth: 0 }));
+ await editor(page).press("ControlOrMeta+v");
+}
+async function save(page: Page) {
+ await page.getByRole("button", { name: "Save changes", exact: true }).click();
+ await expect(page.getByText("Unsaved changes", { exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Reload saved", exact: true })).toBeEnabled();
+ await expect(page.getByRole("alert")).toHaveCount(0);
+}
+
+test("Advanced YAML persists settings and commands through their real resource APIs", async ({ page, request, toolProvider }, info) => {
+ test.setTimeout(120_000);
+ const { company, agent, url } = await setup(request);
+ await json(await request.patch(`/api/agents/${agent.id}/instructions-bundle`, { data: { mode: "managed" } }));
+ const skill = await json(await request.post(`/api/companies/${company.id}/skills`, { data: {
+ name: "YAML proof", slug: "yaml-proof", markdown: "---\nname: yaml-proof\ndescription: Verify saved configuration.\n---\nCheck the persisted state.\n",
+ } }));
+ const connection = await json(await request.post(`/api/companies/${company.id}/tools/connections`, { data: {
+ name: "YAML tools", transport: "mcp_remote", config: { url: toolProvider.url }, status: "draft", enabled: false,
+ } }));
+ await page.goto(`${url}/overview`);
+ await page.getByRole("link", { name: "Advanced", exact: true }).click();
+ await edit(page, value => {
+ value.spec.title = "Saved YAML engineer";
+ value.spec.budgetMonthlyCents = 12300;
+ delete value.spec.adapterConfig.removable;
+ value.spec.adapterConfig.env = { YAML_VARIABLE: { type: "plain", value: "saved variable" } };
+ value.spec.instructions!.files["workflow.md"] = "Saved through YAML.\n";
+ value.spec.skills = [{ key: skill.key, versionId: skill.currentVersionId }];
+ value.spec.tools!.connections.find(row => row.connectionId === connection.id)!.installed = true;
+ value.spec.tools!.profiles = [{ profileId: null, priority: 100, metadata: null, configuration: {
+ profileKey: "yaml-profile", name: "YAML profile", description: null, status: "active", defaultAction: "deny", metadata: null,
+ entries: [{ selectorType: "tool_name", toolName: "read_repository", effect: "include" }],
+ } }];
+ value.spec.tools!.policies = [{ id: null, configuration: { name: "YAML block", policyType: "block", priority: 100, enabled: true, selectors: { agentIds: [agent.id], toolNames: ["delete_repository"] } } }];
+ value.spec.permissions!.canCreateSkills = false;
+ value.spec.secrets!.create = [{ secret: { name: "YAML secret", value: "disposable-yaml-secret" }, bind: { env: ["YAML_SECRET"], api: [] } }];
+ value.spec.apiKeys!.create = [{ name: "YAML automation", scope: { kind: "standard" } }];
+ value.spec.channelOperations!.create = [{ provider: "telegram" }];
+ });
+ await save(page);
+ const token = page.getByRole("textbox", { name: "API key: YAML automation token" });
+ await expect(token).toBeVisible();
+ expect(await token.inputValue()).toMatch(/^pcp_/);
+ await page.reload();
+ const saved = await document(page);
+ expect(saved.spec).toMatchObject({ title: "Saved YAML engineer", budgetMonthlyCents: 12300, permissions: { canCreateSkills: false } });
+ expect(saved.spec.adapterConfig).not.toHaveProperty("removable");
+ expect(saved.spec.adapterConfig.env).toMatchObject({ YAML_VARIABLE: { type: "plain", value: "***REDACTED***" }, YAML_SECRET: { type: "secret_ref" } });
+ expect(stringify(saved)).not.toContain("disposable-yaml-secret");
+ expect(saved.spec.instructions!.files["workflow.md"]).toBe("Saved through YAML.\n");
+ expect(saved.spec.skills).toEqual([{ key: skill.key, versionId: skill.currentVersionId }]);
+ expect(saved.spec.tools!.profiles).toEqual(expect.arrayContaining([expect.objectContaining({ profileId: expect.any(String), configuration: expect.objectContaining({ name: "YAML profile", entries: [expect.objectContaining({ toolName: "read_repository" })] }) })]));
+ expect(saved.spec.tools!.policies).toEqual([expect.objectContaining({ id: expect.any(String), configuration: expect.objectContaining({ name: "YAML block" }) })]);
+ expect(saved.spec.channels).toEqual([expect.objectContaining({ provider: "telegram" })]);
+ expect(saved.spec.apiKeys!.create).toEqual([]);
+ expect(saved.spec.secrets!.create).toEqual([]);
+ expect(saved.spec.channelOperations!.create).toEqual([]);
+ expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).adapterConfig.env.YAML_SECRET.type).toBe("secret_ref");
+ expect((await json(await request.get(`/api/tool-connections/${connection.id}/installs`))).installs).toContainEqual(expect.objectContaining({ targetType: "agent", targetId: agent.id }));
+ const key = (await json(await request.get(`/api/agents/${agent.id}/keys`))).find((row: { name: string }) => row.name === "YAML automation");
+ await edit(page, value => {
+ value.spec.apiKeys!.revoke = [key.id];
+ value.spec.channels![0].settings.allowUnlinkedPeople = true;
+ delete value.spec.instructions!.files["workflow.md"];
+ value.spec.tools!.profiles = [];
+ value.spec.tools!.policies = [];
+ value.spec.tools!.connections.find(row => row.connectionId === connection.id)!.installed = false;
+ value.spec.skills = [];
+ });
+ await save(page);
+ await page.reload();
+ const cleared = await document(page);
+ expect(cleared.spec.instructions!.files).not.toHaveProperty("workflow.md");
+ expect(cleared.spec.skills).toEqual([]);
+ expect(cleared.spec.tools!.profiles).toEqual([]);
+ expect(cleared.spec.tools!.policies).toEqual([]);
+ expect(cleared.spec.channels![0].settings.allowUnlinkedPeople).toBe(true);
+ expect(cleared.spec.channels![0].settings).not.toHaveProperty("communicationInstructions");
+ expect((await json(await request.get(`/api/agents/${agent.id}/keys`))).find((row: { id: string }) => row.id === key.id).revokedAt).not.toBeNull();
+ await page.screenshot({ path: info.outputPath("saved-agent-yaml.png") });
+});
+
+test("stale drafts and invalid commands leave saved configuration unchanged", async ({ page, request }) => {
+ const { agent, url } = await setup(request);
+ await page.goto(`${url}/advanced`);
+ await edit(page, value => { value.spec.title = "Local draft"; });
+ await json(await request.patch(`/api/agents/${agent.id}`, { data: { title: "Other operator" } }));
+ await page.getByRole("button", { name: "Save changes", exact: true }).click();
+ await expect(page.getByRole("alert")).toContainText("changed while you were editing");
+ expect((await document(page)).spec.title).toBe("Local draft");
+ expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).title).toBe("Other operator");
+ await page.getByRole("button", { name: "Reload saved", exact: true }).click();
+ await expect.poll(async () => (await document(page)).spec.title).toBe("Other operator");
+ await edit(page, value => { value.observed = {}; });
+ await expect(page.getByRole("alert")).toContainText("observed is read-only");
+ await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
+});
+
+test("renaming an agent keeps newly created key tokens visible through the route change", async ({ page, request }) => {
+ const { agent, url } = await setup(request);
+ await page.goto(`${url}/advanced`);
+ await edit(page, value => {
+ value.spec.name = "Renamed YAML engineer";
+ value.spec.apiKeys!.create = [{ name: "Rename key", scope: { kind: "standard" } }];
+ });
+ await save(page);
+ await expect(page).toHaveURL(/\/agents\/renamed-yaml-engineer\/advanced$/);
+ const token = page.getByRole("textbox", { name: "API key: Rename key token" });
+ await expect(token).toBeVisible();
+ const created = await token.inputValue();
+ expect(created).toMatch(/^pcp_/);
+ expect(stringify(await document(page))).not.toContain(created);
+ expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(1);
+ await edit(page, value => { value.spec.title = "Another saved edit"; });
+ await save(page);
+ await expect(token).toHaveValue(created);
+});
+
+test("a lost creation response requires reload and cannot blindly replay commands", async ({ page, request }) => {
+ const { agent, url } = await setup(request);
+ let creates = 0;
+ await page.route(`**/api/agents/${agent.id}/keys?*`, async route => {
+ if (route.request().method() !== "POST") return route.continue();
+ creates++;
+ if (creates === 1) return route.continue();
+ await route.fetch();
+ return route.abort("failed");
+ });
+ await page.goto(`${url}/advanced`);
+ await edit(page, value => {
+ value.spec.name = "Partially renamed YAML engineer";
+ value.spec.title = "Saved before lost response";
+ value.spec.apiKeys!.create = ["First key", "Second key"].map(name => ({ name, scope: { kind: "standard" } }));
+ });
+ await page.getByRole("button", { name: "Save changes", exact: true }).click();
+ await expect(page.getByRole("alert")).toContainText("Reload saved before saving again");
+ await expect(page.getByRole("textbox", { name: "API key: First key token" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
+ expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).title).toBe("Saved before lost response");
+ expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(2);
+ expect((await document(page)).spec.apiKeys!.create).toHaveLength(2);
+ await page.getByRole("button", { name: "Reload saved", exact: true }).click();
+ await expect.poll(async () => (await document(page)).spec.apiKeys!.create).toEqual([]);
+ await expect(page).toHaveURL(/\/agents\/partially-renamed-yaml-engineer\/advanced$/);
+ await expect(page.getByRole("textbox", { name: "API key: First key token" })).toBeVisible();
+ await edit(page, value => { value.spec.title = "Recovered save"; });
+ await save(page);
+ expect(creates).toBe(2);
+ expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(2);
+});
+
+test("switching instructions location loads existing destination files without overwriting them", async ({ page, request }) => {
+ const { agent, url } = await setup(request);
+ await json(await request.patch(`/api/agents/${agent.id}/instructions-bundle`, { data: { mode: "managed" } }));
+ const destination = await mkdtemp(path.join(os.tmpdir(), "agent-yaml-instructions-"));
+ await writeFile(path.join(destination, "AGENTS.md"), "Keep destination instructions.\n");
+ await page.goto(`${url}/advanced`);
+ await edit(page, value => { value.spec.instructions!.mode = "external"; value.spec.instructions!.rootPath = destination; });
+ await save(page);
+ expect((await document(page)).spec.instructions!.files["AGENTS.md"]).toBe("Keep destination instructions.\n");
+ expect(await readFile(path.join(destination, "AGENTS.md"), "utf8")).toBe("Keep destination instructions.\n");
+});
+
+test("revision restore is an explicit separate save and reloads the restored configuration", async ({ page, request }) => {
+ const { agent, url } = await setup(request);
+ await page.goto(`${url}/advanced`);
+ await edit(page, value => { value.spec.title = "Before restore"; });
+ await save(page);
+ const revisions = await json(await request.get(`/api/agents/${agent.id}/config-revisions`));
+ const revision = revisions.find((row: { changedKeys: string[] }) => row.changedKeys.includes("title"));
+ expect(revision).toBeTruthy();
+ await edit(page, value => { value.spec.title = "After restore target"; });
+ await save(page);
+ await edit(page, value => { value.spec.revisions!.restore = revision.id; value.spec.title = "Conflicting edit"; });
+ await expect(page.getByRole("alert")).toContainText("Restore a revision in its own save");
+ await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
+ await edit(page, value => { value.spec.title = "After restore target"; });
+ await save(page);
+ await page.reload();
+ const restored = await document(page);
+ expect(restored.spec.title).toBe("Before restore");
+ expect(restored.spec.revisions!.restore).toBeNull();
+});
diff --git a/ui/package.json b/ui/package.json
index 279b2553b14..e59f87e405d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -31,10 +31,15 @@
"dependencies": {
"@assistant-ui/react": "0.15.22",
"@base-ui/react": "^1.7.0",
+ "@codemirror/lang-yaml": "^6.1.3",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/state": "^6.7.2",
+ "@codemirror/view": "^6.43.11",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lexical/link": "0.48.0",
+ "@lezer/highlight": "^1.2.3",
"@mdxeditor/editor": "^4.2.3",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@@ -58,6 +63,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "codemirror": "^6.0.2",
"i18next": "^26.4.2",
"lexical": "0.48.0",
"lucide-react": "^1.48.0",
@@ -72,6 +78,7 @@
"react-router-dom": "^7.18.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
+ "yaml": "^2.8.1",
"yjs": "13.6.32"
},
"devDependencies": {
diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts
index 9f97ce68bfe..a20eb913bab 100644
--- a/ui/src/api/agents.ts
+++ b/ui/src/api/agents.ts
@@ -154,7 +154,7 @@ export const agentsApi = {
}
},
getConfiguration: (id: string, companyId?: string) =>
- api.get>(agentPath(id, companyId, "/configuration")),
+ api.get(agentPath(id, companyId, "/configuration")),
listConfigRevisions: (id: string, companyId?: string) =>
api.get(agentPath(id, companyId, "/config-revisions")),
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
@@ -168,7 +168,7 @@ export const agentsApi = {
updateRuntimeSettings: (id: string, runtimeSettings: import("@paperclipai/shared").AgentRuntimeSettingsPatch, companyId: string) =>
api.patch(agentPath(id, companyId), { runtimeSettings }),
update: (id: string, data: Record, companyId?: string) =>
- api.patch(agentPath(id, companyId), data),
+ api.patch(agentPath(id, companyId), data),
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
api.patch(agentPath(id, companyId, "/permissions"), data),
instructionsBundle: (id: string, companyId?: string) =>
diff --git a/ui/src/components/AgentContextualSidebar.test.tsx b/ui/src/components/AgentContextualSidebar.test.tsx
index 50cfae97ab0..8641ef1c6a3 100644
--- a/ui/src/components/AgentContextualSidebar.test.tsx
+++ b/ui/src/components/AgentContextualSidebar.test.tsx
@@ -63,6 +63,7 @@ describe("AgentContextualSidebar", () => {
expect(markup).toContain("border-r border-border bg-background");
expect(markup).toContain('data-slot="contextual-sidebar-nav"');
expect(markup).toContain('href="/agents/codexcoder/overview"');
+ expect(markup).toContain('href="/agents/codexcoder/advanced"');
expect(markup).toContain('href="/agents/codexcoder/permissions"');
expect(markup).toContain('href="/agents/codexcoder/api-keys"');
expect(markup).toContain('href="/activity?mode=agents&agentId=agent-1"');
diff --git a/ui/src/components/AgentContextualSidebar.tsx b/ui/src/components/AgentContextualSidebar.tsx
index 78e1838c488..40b7b470d09 100644
--- a/ui/src/components/AgentContextualSidebar.tsx
+++ b/ui/src/components/AgentContextualSidebar.tsx
@@ -3,6 +3,7 @@ import {
Activity,
BadgeDollarSign,
BookOpenText,
+ FileCode2,
History,
KeyRound,
Library,
@@ -39,6 +40,7 @@ const localIcons = {
permissions: ShieldCheck,
"api-keys": KeyRound,
revisions: History,
+ advanced: FileCode2,
} satisfies Record;
const auditItems = [
diff --git a/ui/src/index.css b/ui/src/index.css
index 487f8be6c07..63dec5a0af1 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -3295,6 +3295,26 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
── end allowlist ── */
/* Agent settings composition, shared with Storybook. */
+.agent-yaml-editor .cm-editor {
+ height: var(--sz-480px);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ background: var(--background);
+ color: var(--foreground);
+}
+.agent-yaml-editor .cm-scroller { overflow: auto; }
+.agent-yaml-editor .cm-gutters {
+ background: var(--muted);
+ color: var(--muted-foreground);
+ border-color: var(--border);
+}
+.agent-yaml-editor .cm-activeLine,
+.agent-yaml-editor .cm-activeLineGutter { background: var(--accent); }
+.agent-yaml-editor .cm-cursor { border-left-color: var(--foreground); }
+.agent-yaml-editor .cm-selectionBackground,
+.agent-yaml-editor .cm-focused .cm-selectionBackground { background: var(--accent); }
+.agent-yaml-editor .cm-focused { outline-color: var(--ring); }
+
.agent-settings-form > .relative {
@apply flex flex-col gap-8;
}
diff --git a/ui/src/lib/agent-configuration-yaml.test.ts b/ui/src/lib/agent-configuration-yaml.test.ts
new file mode 100644
index 00000000000..0a65d0d4b11
--- /dev/null
+++ b/ui/src/lib/agent-configuration-yaml.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { appearanceForPalette, type AgentConfigurationSource } from "@paperclipai/shared";
+import { agentConfigurationPatch, parseAgentConfigurationYaml, serializeAgentConfiguration } from "./agent-configuration-yaml";
+
+export const configuration: AgentConfigurationSource = {
+ id: "11111111-1111-4111-8111-111111111111", name: "Codie", role: "engineer", title: null,
+ icon: null, appearance: appearanceForPalette("bubblegum-sky"), reportsTo: null,
+ capabilities: "Write code\nReview changes\n", adapterType: "codex_local",
+ adapterConfig: { model: "gpt-5", env: { API_KEY: { type: "plain", value: "***REDACTED***" } }, extra: { enabled: true } },
+ runtimeConfig: { heartbeat: { enabled: false, intervalSec: 3600 }, custom: { nested: [1, "on", false] } },
+ defaultEnvironmentId: null, budgetMonthlyCents: 12000, metadata: null,
+};
+
+describe("agent configuration YAML", () => {
+ it("round trips the full configuration, multiline text, redactions and adapter extensions", () => {
+ const text = serializeAgentConfiguration(configuration);
+ expect(text).toContain("apiVersion: paperclip/v1");
+ expect(text).toContain("capabilities: |");
+ expect(agentConfigurationPatch(parseAgentConfigurationYaml(text), configuration)).toEqual({});
+ });
+
+ it("sends only changed fields and replaces the adapter map when a key is removed", () => {
+ const doc = parseAgentConfigurationYaml(serializeAgentConfiguration(configuration));
+ delete doc.spec.adapterConfig.extra;
+ doc.spec.budgetMonthlyCents = 20000;
+ expect(agentConfigurationPatch(doc, configuration)).toEqual({
+ adapterConfig: { model: "gpt-5", env: configuration.adapterConfig.env },
+ replaceAdapterConfig: true, budgetMonthlyCents: 20000,
+ });
+ });
+
+ it("treats formatting, comments and reordered maps as a no-op", () => {
+ const text = serializeAgentConfiguration({ ...configuration, adapterConfig: { extra: { enabled: true }, env: configuration.adapterConfig.env, model: "gpt-5" } });
+ expect(agentConfigurationPatch(parseAgentConfigurationYaml(`# comment\n${text}`), configuration)).toEqual({});
+ });
+
+ it.each([
+ ["unknown version", (text: string) => text.replace("paperclip/v1", "paperclip/v2")],
+ ["unknown field", (text: string) => `${text} typo: true\n`],
+ ["missing field", (text: string) => text.replace(" name: Codie\n", "")],
+ ["invalid budget", (text: string) => text.replace("budgetMonthlyCents: 12000", "budgetMonthlyCents: -1")],
+ ["invalid environment", (text: string) => text.replace("defaultEnvironmentId: null", "defaultEnvironmentId: unknown")],
+ ["duplicate key", (text: string) => `${text} name: duplicate\n`],
+ ["second document", (text: string) => `${text}---\nkind: Agent\n`],
+ ["alias", (text: string) => text.replace("metadata: null", "metadata: {a: &a [1], b: *a}")],
+ ["NaN", (text: string) => text.replace("metadata: null", "metadata: {value: .nan}")],
+ ["merge key", (text: string) => text.replace("metadata: null", "metadata: {<<: {name: Other}}")],
+ ["reserved key", (text: string) => text.replace("metadata: null", "metadata: {__proto__: {bad: true}}")],
+ ["permission mutation", (text: string) => `${text} permissions: {canCreateAgents: true}\n`],
+ ["status mutation", (text: string) => `${text} status: idle\n`],
+ ])("rejects %s before saving", (_name, modify) => {
+ expect(() => parseAgentConfigurationYaml(modify(serializeAgentConfiguration(configuration)))).toThrow();
+ });
+});
diff --git a/ui/src/lib/agent-configuration-yaml.ts b/ui/src/lib/agent-configuration-yaml.ts
new file mode 100644
index 00000000000..9311a322233
--- /dev/null
+++ b/ui/src/lib/agent-configuration-yaml.ts
@@ -0,0 +1,68 @@
+import { isAlias, parseDocument, stringify, visit } from "yaml";
+import {
+ agentConfigurationDocumentSchema,
+ agentConfigurationSpec,
+ canonicalAgentConfiguration,
+ type AgentConfigurationDocument,
+ type AgentConfigurationSource,
+} from "@paperclipai/shared";
+
+export function serializeAgentConfiguration(agent: AgentConfigurationSource): string {
+ return serializeAgentConfigurationDocument({ apiVersion: "paperclip/v1", kind: "Agent", spec: agentConfigurationSpec(agent) } as AgentConfigurationDocument);
+}
+
+export function serializeAgentConfigurationDocument(document: AgentConfigurationDocument): string {
+ return stringify(document, {
+ lineWidth: 0, aliasDuplicateObjects: false,
+ });
+}
+
+function assertJson(value: unknown, depth = 0): void {
+ if (depth > 50) throw new Error("Configuration nesting must not exceed 50 levels.");
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
+ if (typeof value === "number" && Number.isFinite(value)) return;
+ if (Array.isArray(value)) { value.forEach(item => assertJson(item, depth + 1)); return; }
+ if (value && typeof value === "object") {
+ for (const [key, item] of Object.entries(value)) {
+ if (["__proto__", "constructor", "prototype", "<<"].includes(key)) {
+ throw new Error(`Unsupported configuration key: ${key}`);
+ }
+ assertJson(item, depth + 1);
+ }
+ return;
+ }
+ throw new Error("Configuration must contain only JSON-compatible values (no NaN or infinity).");
+}
+
+export function parseAgentConfigurationYaml(source: string): AgentConfigurationDocument {
+ if (source.length > 8 * 1024 * 1024 || new TextEncoder().encode(source).byteLength > 8 * 1024 * 1024) throw new Error("Configuration must be smaller than 8 MiB.");
+ const document = parseDocument(source, { version: "1.2", schema: "core", uniqueKeys: true, stringKeys: true });
+ const problem = document.errors[0] ?? document.warnings[0];
+ if (problem) throw new Error(problem.message);
+ if (document.directives?.yaml.version !== "1.2") throw new Error("Use YAML 1.2.");
+ visit(document, (_key, node) => {
+ if (isAlias(node)) throw new Error("YAML aliases are not supported. Write each value explicitly.");
+ if (node && typeof node === "object" && "anchor" in node && node.anchor) throw new Error("YAML anchors are not supported.");
+ if (node && typeof node === "object" && "tag" in node && node.tag) throw new Error("YAML tags are not supported.");
+ });
+ const value: unknown = document.toJS({ maxAliasCount: 0 });
+ assertJson(value);
+ const parsed = agentConfigurationDocumentSchema.safeParse(value);
+ if (!parsed.success) {
+ throw new Error(parsed.error.issues.map(issue => `${issue.path.join(".") || "document"}: ${issue.message}`).join("\n"));
+ }
+ return parsed.data;
+}
+
+export function agentConfigurationPatch(document: AgentConfigurationDocument, saved: AgentConfigurationSource) {
+ const before = agentConfigurationSpec(saved);
+ const patch: Record = {};
+ for (const key of Object.keys(before) as Array) {
+ const value = document.spec[key];
+ if (canonicalAgentConfiguration(value) !== canonicalAgentConfiguration(before[key as keyof typeof before])) {
+ patch[key] = value;
+ }
+ }
+ if (Object.hasOwn(patch, "adapterConfig")) patch.replaceAdapterConfig = true;
+ return patch;
+}
diff --git a/ui/src/lib/agent-configuration/channels.ts b/ui/src/lib/agent-configuration/channels.ts
new file mode 100644
index 00000000000..b599b9a52da
--- /dev/null
+++ b/ui/src/lib/agent-configuration/channels.ts
@@ -0,0 +1,52 @@
+import type { AgentConfigurationDocument } from "@paperclipai/shared";
+import { chatEndpointsApi } from "../../api/chatEndpoints";
+import { equal, type AgentConfigurationSnapshot } from "./load";
+import { requireItem, requireExplicitFields, unique, type AddConfigurationStep } from "./steps";
+
+export function planChannelChanges(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep) {
+ const { id: agentId, companyId } = snapshot.agent;
+ const before = snapshot.document.spec.channels!;
+ const next = document.spec.channels;
+ if (next) {
+ unique(next.map(row => row.id), "channels");
+ for (const row of next) {
+ const previous = requireItem(before, channel => channel.id === row.id, `Channel ${row.id}`);
+ if (row.provider !== previous.provider) throw new Error("A channel provider cannot be changed. Create a new channel instead.");
+ unique(row.resources.map(resource => resource.id), `channels.${row.id}.resources`);
+ for (const resource of row.resources) {
+ const existing = requireItem(previous.resources, value => value.id === resource.id, `Channel resource ${resource.id}`);
+ requireExplicitFields(existing, resource, `channels.${row.id}.resources.${resource.id}`);
+ }
+ if (!equal(previous.settings, row.settings)) {
+ requireExplicitFields(previous.settings, row.settings, `channels.${row.id}.settings`);
+ const changes = Object.fromEntries(Object.entries(row.settings).filter(([key, value]) =>
+ !equal(value, previous.settings[key as keyof typeof previous.settings])));
+ add(`Channel settings: ${row.id}`, () => chatEndpointsApi.update(row.id, changes));
+ }
+ if (!equal(previous.resources, row.resources)) {
+ const resources = previous.resources.map(resource => row.resources.find(value => value.id === resource.id) ?? { ...resource, enabled: false });
+ add(`Channel resources: ${row.id}`, () => chatEndpointsApi.updateResources(row.id, resources));
+ }
+ }
+ for (const previous of before) if (!next.some(row => row.id === previous.id)) {
+ add(`Remove channel: ${previous.id}`, () => chatEndpointsApi.setup(previous.id, { action: "remove" }));
+ }
+ }
+ const operations = document.spec.channelOperations;
+ if (!operations) return;
+ unique(operations.setup.map(row => row.id), "channelOperations.setup");
+ for (const input of operations.create) add(`Create ${input.provider} channel`, () => chatEndpointsApi.create(companyId, { ...input, assignedAgentId: agentId }));
+ for (const operation of operations.setup) {
+ const previous = requireItem(before, channel => channel.id === operation.id, `Channel ${operation.id}`);
+ if (operation.input.action === "remove" && next && !equal(next.find(row => row.id === operation.id), previous)) throw new Error(`Channel ${operation.id} cannot be edited and removed in the same save.`);
+ if (next && !next.some(row => row.id === operation.id)) throw new Error(`Channel ${operation.id} is both removed and configured.`);
+ add(`Channel ${operation.input.action}: ${operation.id}`, () => chatEndpointsApi.setup(operation.id, operation.input));
+ }
+ unique(operations.revokeIdentityLinks.map(row => `${row.endpointId}/${row.principalId}`), "channelOperations.revokeIdentityLinks");
+ for (const operation of operations.revokeIdentityLinks) {
+ if ((next && !next.some(row => row.id === operation.endpointId)) || operations.setup.some(row => row.id === operation.endpointId && row.input.action === "remove")) throw new Error(`Channel ${operation.endpointId} cannot be removed and have its identities edited in the same save.`);
+ const channel = requireItem(snapshot.channels, row => row.detail.id === operation.endpointId, `Channel ${operation.endpointId}`);
+ requireItem(channel.principals, row => row.principalId === operation.principalId, `Identity ${operation.principalId}`);
+ add(`Revoke channel identity: ${operation.principalId}`, () => chatEndpointsApi.revokeLink(operation.endpointId, operation.principalId));
+ }
+}
diff --git a/ui/src/lib/agent-configuration/load.ts b/ui/src/lib/agent-configuration/load.ts
new file mode 100644
index 00000000000..833ff130eef
--- /dev/null
+++ b/ui/src/lib/agent-configuration/load.ts
@@ -0,0 +1,119 @@
+import {
+ agentConfigurationSpec, canonicalAgentConfiguration,
+ type AgentConfigurationDocument, type AgentInstructionsFileDetail, type ToolProfileWithDetails, type ToolPolicy,
+} from "@paperclipai/shared";
+import { agentsApi } from "../../api/agents";
+import { toolsApi } from "../../api/tools";
+import { chatEndpointsApi } from "../../api/chatEndpoints";
+import { secretsApi } from "../../api/secrets";
+
+export const ownedAdapterKeys = ["paperclipSkillSync", "instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"] as const;
+export const equal = (a: unknown, b: unknown) => canonicalAgentConfiguration(a) === canonicalAgentConfiguration(b);
+export const jsonValue = (value: T): T => JSON.parse(JSON.stringify(value));
+
+export async function mapBounded(items: T[], read: (item: T) => Promise): Promise {
+ const results: R[] = new Array(items.length);
+ let next = 0;
+ await Promise.all(Array.from({ length: Math.min(items.length, 4) }, async () => {
+ while (next < items.length) { const index = next++; results[index] = await read(items[index]); }
+ }));
+ return results;
+}
+
+export function profileConfiguration(profile: ToolProfileWithDetails) {
+ return {
+ profileKey: profile.profileKey, name: profile.name, description: profile.description,
+ status: profile.status, defaultAction: profile.defaultAction, metadata: profile.metadata,
+ entries: profile.entries.map(({ selectorType, effect, applicationId, connectionId, catalogEntryId, toolName, riskLevel, conditions }) =>
+ ({ selectorType, effect, applicationId, connectionId, catalogEntryId, toolName, riskLevel, conditions })),
+ };
+}
+
+export function policyConfiguration(policy: ToolPolicy) {
+ const { name, description, policyType, priority, enabled, selectors, conditions, config } = policy;
+ return { name, description, policyType, priority, enabled, selectors, conditions, config };
+}
+
+export function policyAgentIds(policy: Pick): string[] {
+ return [policy.selectors.agentId, policy.selectors.agentIds].flatMap(value =>
+ Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : typeof value === "string" ? [value] : []);
+}
+
+export async function loadAgentConfiguration(id: string, companyId: string) {
+ const core = await agentsApi.getConfiguration(id, companyId);
+ const [agent, bundle, skills, connections, profiles, policies, endpoints, keys, revisions, secrets, proposals] = await Promise.all([
+ agentsApi.get(id, companyId), agentsApi.instructionsBundle(id, companyId), agentsApi.skills(id, companyId),
+ toolsApi.listConnections(companyId), toolsApi.listProfiles(companyId), toolsApi.listPolicies(companyId),
+ chatEndpointsApi.list(companyId), agentsApi.listKeys(id, companyId), agentsApi.listConfigRevisions(id, companyId),
+ secretsApi.list(companyId), secretsApi.listProposals(companyId),
+ ]);
+ const [files, channels] = await Promise.all([
+ mapBounded(bundle.files.filter(file => file.editable && !file.binary && !file.virtual), file => agentsApi.instructionsFile(id, file.path, companyId)),
+ mapBounded(endpoints.filter(endpoint => endpoint.assignedAgentId === id && endpoint.status !== "archived"), async endpoint => {
+ const [detail, resources, principals] = await Promise.all([
+ chatEndpointsApi.get(endpoint.id), chatEndpointsApi.listResources(endpoint.id), chatEndpointsApi.listPrincipals(endpoint.id),
+ ]);
+ if (detail.assignedAgentId !== id || detail.companyId !== companyId) throw new Error("Channel ownership changed. Reload configuration.");
+ return { detail, resources, principals };
+ }),
+ ]);
+ const adapterConfig = { ...core.adapterConfig };
+ for (const key of ownedAdapterKeys) delete adapterConfig[key];
+ const directPolicies = policies.policies.filter(policy => {
+ const ids = policyAgentIds(policy); return ids.length > 0 && ids.every(value => value === id);
+ });
+ const directProfiles = profiles.profiles.flatMap(profile => {
+ const binding = profile.bindings.find(binding => binding.targetType === "agent" && binding.targetId === id);
+ return binding ? [{ profileId: profile.id, updateSharedDefinition: false, priority: binding.priority, metadata: binding.metadata, configuration: profileConfiguration(profile) }] : [];
+ });
+ const document = jsonValue({
+ apiVersion: "paperclip/v1", kind: "Agent",
+ spec: {
+ ...agentConfigurationSpec(core), adapterConfig,
+ permissions: {
+ canCreateAgents: Boolean(agent.permissions.canCreateAgents), canCreateSkills: agent.permissions.canCreateSkills !== false,
+ canAssignTasks: Boolean(agent.access?.canAssignTasks), trustPreset: agent.permissions.trustPreset ?? "standard",
+ ...(agent.permissions.authorizationPolicy ? { authorizationPolicy: agent.permissions.authorizationPolicy } : {}),
+ },
+ instructions: { mode: bundle.mode, rootPath: bundle.rootPath, entryFile: bundle.entryFile,
+ files: Object.fromEntries(files.map(file => [file.path, file.content])) },
+ skills: skills.desiredSkillEntries ?? skills.desiredSkills.map(key => ({ key, versionId: null })),
+ tools: {
+ connections: connections.connections.map(connection => ({ connectionId: connection.id,
+ installed: (connection.installs ?? []).some(install => install.targetType === "agent" && install.targetId === id) })),
+ profiles: directProfiles,
+ policies: directPolicies.map(policy => ({ id: policy.id, configuration: policyConfiguration(policy) })),
+ },
+ channels: channels.map(({ detail, resources }) => ({ id: detail.id, provider: detail.provider, settings: {
+ executionDefaults: detail.executionDefaults ?? null,
+ ...(detail.provider === "slack" ? { communicationInstructions: detail.communicationInstructions ?? "" } : {}),
+ allowUnlinkedPeople: detail.allowUnlinkedPeople,
+ ...(detail.allowDirectMessages !== undefined ? { allowDirectMessages: detail.allowDirectMessages } : {}),
+ ...(detail.provider === "slack" && detail.requireAtMention !== undefined ? { requireAtMention: detail.requireAtMention } : {}),
+ ...(detail.allowGroupChats !== undefined ? { allowGroupChats: detail.allowGroupChats } : {}),
+ ...(detail.setup?.slackApp ? { slackApp: detail.setup.slackApp } : {}),
+ ...(detail.setup?.slackSetupMethod ? { slackSetupMethod: detail.setup.slackSetupMethod } : {}),
+ }, resources: resources.map(resource => ({ id: resource.id, enabled: resource.enabled, executionDefaults: resource.executionDefaults ?? null })) })),
+ apiKeys: { create: [], revoke: [] }, revisions: { restore: null },
+ secrets: { create: [], rotate: [], update: [], proposals: [] },
+ channelOperations: { create: [], setup: [], revokeIdentityLinks: [] },
+ },
+ observed: {
+ apiKeys: keys, revisions: revisions.map(({ id, source, changedKeys, createdAt }) => ({ id, source, changedKeys, createdAt })),
+ instructionFiles: bundle.files.filter(file => !file.editable || file.binary || file.virtual), instructionWarnings: bundle.warnings,
+ skillWarnings: skills.warnings,
+ connections: connections.connections.map(connection => ({ id: connection.id, name: connection.name,
+ inherited: (connection.installs ?? []).some(install => install.targetType === "company" && install.targetId === companyId) })),
+ availableProfiles: profiles.profiles.map(profile => ({ id: profile.id, configuration: profileConfiguration(profile) })),
+ inheritedProfiles: profiles.profiles.filter(profile => profile.bindings.some(binding => binding.targetType === "company" && binding.targetId === companyId)).map(profile => ({ id: profile.id, configuration: profileConfiguration(profile) })),
+ inheritedPolicies: policies.policies.filter(policy => !directPolicies.includes(policy) && (policyAgentIds(policy).length === 0 || policyAgentIds(policy).includes(id))).map(policy => ({ id: policy.id, configuration: policyConfiguration(policy) })),
+ channels: channels.map(({ detail, resources, principals }) => ({ id: detail.id, status: detail.status, botLabel: detail.botLabel, resources, principals })),
+ secrets: secrets.map(({ id, name, key, provider, latestVersion }) => ({ id, name, key, provider, latestVersion })),
+ secretProposals: proposals.filter(proposal => (proposal.target?.id === id || proposal.proposedBy.id === id)),
+ },
+ }) as AgentConfigurationDocument;
+ return { agent, core, bundle, files: new Map(files.map(file => [file.path, file])),
+ connections: connections.connections, profiles: profiles.profiles, policies: policies.policies, channels, keys, revisions, secrets, proposals, document };
+}
+
+export type AgentConfigurationSnapshot = Awaited>;
diff --git a/ui/src/lib/agent-configuration/save.test.ts b/ui/src/lib/agent-configuration/save.test.ts
new file mode 100644
index 00000000000..e497d0b9c0b
--- /dev/null
+++ b/ui/src/lib/agent-configuration/save.test.ts
@@ -0,0 +1,315 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { appearanceForPalette, type AgentConfigurationDocument } from "@paperclipai/shared";
+import { parseAgentConfigurationYaml, serializeAgentConfigurationDocument } from "../agent-configuration-yaml";
+import { loadAgentConfiguration } from "./load";
+import { ConfigurationSaveInterrupted, planAgentConfiguration, saveAgentConfiguration } from "./save";
+
+const api = vi.hoisted(() => ({
+ agents: Object.fromEntries(["getConfiguration", "get", "instructionsBundle", "instructionsFile", "skills", "listKeys", "listConfigRevisions", "update", "updateInstructionsBundle", "saveInstructionsFile", "deleteInstructionsFile", "syncSkills", "updatePermissions", "createKey", "revokeKey", "rollbackConfigRevision"].map(key => [key, vi.fn()])),
+ tools: Object.fromEntries(["listConnections", "listProfiles", "listPolicies", "getConnectionInstalls", "putConnectionInstalls", "createProfile", "updateProfile", "bindProfile", "unbindProfile", "createPolicy", "updatePolicy", "deletePolicy"].map(key => [key, vi.fn()])),
+ channels: Object.fromEntries(["list", "get", "listResources", "listPrincipals", "update", "updateResources", "create", "setup", "revokeLink"].map(key => [key, vi.fn()])),
+ secrets: Object.fromEntries(["list", "listProposals", "create", "rotate", "update", "approveProposal", "rejectProposal"].map(key => [key, vi.fn()])),
+}));
+vi.mock("../../api/agents", () => ({ agentsApi: api.agents }));
+vi.mock("../../api/tools", () => ({ toolsApi: api.tools }));
+vi.mock("../../api/chatEndpoints", () => ({ chatEndpointsApi: api.channels }));
+vi.mock("../../api/secrets", () => ({ secretsApi: api.secrets }));
+const agentId = "11111111-1111-4111-8111-111111111111";
+const resourceId = "22222222-2222-4222-8222-222222222222";
+const agent = {
+ id: agentId, companyId: "company", name: "Codie", role: "engineer", title: null, icon: null,
+ appearance: appearanceForPalette("bubblegum-sky"), reportsTo: null, capabilities: null,
+ adapterType: "process", adapterConfig: { command: "echo", instructionsBundleMode: "managed", paperclipSkillSync: { desiredSkills: ["paperclip"] } },
+ runtimeConfig: {}, defaultEnvironmentId: null, budgetMonthlyCents: 0, metadata: null,
+ permissions: { canCreateAgents: false, canCreateSkills: true }, access: { canAssignTasks: false },
+ configurationRevision: "a".repeat(64),
+};
+const file = { path: "AGENTS.md", content: "# Codie\n", contentHash: "hash", revision: { id: "revision-file" }, editable: true, binary: false };
+const installs = [{ targetType: "agent", targetId: agentId }, { targetType: "agent", targetId: "another-agent" }];
+const connection = { id: "connection", name: "GitHub", installs };
+const channel = { id: "channel", companyId: "company", assignedAgentId: agentId, provider: "slack", status: "active", allowUnlinkedPeople: false };
+const progress = vi.fn(), receipt = vi.fn();
+const copy = (value: T): T => structuredClone(value);
+async function snapshot() { return loadAgentConfiguration(agentId, "company"); }
+function roundTrip(document: AgentConfigurationDocument) { return parseAgentConfigurationYaml(serializeAgentConfigurationDocument(document)); }
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ api.agents.getConfiguration.mockResolvedValue(copy(agent)); api.agents.get.mockResolvedValue(copy(agent));
+ api.agents.instructionsBundle.mockResolvedValue({ mode: "managed", rootPath: "/managed/codie", entryFile: "AGENTS.md", editable: true, files: [file], warnings: [] });
+ api.agents.instructionsFile.mockResolvedValue(file);
+ api.agents.skills.mockResolvedValue({ desiredSkills: ["paperclip"], desiredSkillEntries: [{ key: "paperclip", versionId: resourceId }], warnings: [] });
+ api.agents.listKeys.mockResolvedValue([{ id: "key", name: "Previous", scope: { kind: "standard" }, revokedAt: null }]);
+ api.agents.listConfigRevisions.mockResolvedValue([{ id: "revision", source: "board", changedKeys: ["name"] }]);
+ api.tools.listConnections.mockResolvedValue({ connections: [connection] });
+ api.tools.listProfiles.mockResolvedValue({ profiles: [] }); api.tools.listPolicies.mockResolvedValue({ policies: [] });
+ api.tools.getConnectionInstalls.mockResolvedValue({ installs });
+ api.tools.createProfile.mockResolvedValue({ id: "new-profile" });
+ api.channels.list.mockResolvedValue([channel]); api.channels.get.mockResolvedValue(channel);
+ api.channels.listResources.mockResolvedValue([{ id: resourceId, enabled: true }]); api.channels.listPrincipals.mockResolvedValue([{ principalId: "principal" }]);
+ api.secrets.list.mockResolvedValue([{ id: "secret", name: "Token", provider: "local_encrypted", latestVersion: 1 }]);
+ api.secrets.listProposals.mockResolvedValue([{ id: "proposal", target: { id: agentId }, proposedBy: { id: agentId } }]);
+ api.agents.createKey.mockResolvedValue({ id: "new-key", name: "Automation", token: "one-time-token" });
+ api.secrets.create.mockResolvedValue({ id: "new-secret", name: "Token" });
+});
+
+describe("complete agent configuration", () => {
+ it("loads every resource section and round trips to a no-op without credential values", async () => {
+ const saved = await snapshot();
+ const doc = roundTrip(saved.document);
+ expect(doc.spec.instructions?.files["AGENTS.md"]).toBe(file.content);
+ expect(doc.spec.skills).toEqual([{ key: "paperclip", versionId: resourceId }]);
+ expect(doc.spec.adapterConfig).toEqual({ command: "echo" });
+ expect(doc.observed?.secretProposals).toHaveLength(1);
+ expect(planAgentConfiguration(saved, doc)).toEqual([]);
+ await saveAgentConfiguration(saved, doc, receipt, progress);
+ expect(api.agents.getConfiguration).toHaveBeenCalledTimes(1);
+ expect(api.agents.update).not.toHaveBeenCalled();
+ });
+
+ it("round trips existing profiles and agent policies without rewriting shared definitions", async () => {
+ api.tools.listProfiles.mockResolvedValue({ profiles: [{ id: "profile", profileKey: "read", name: "Read", description: null,
+ status: "active", defaultAction: "deny", metadata: null,
+ bindings: [{ targetType: "agent", targetId: agentId, priority: 100, metadata: null }],
+ entries: [{ selectorType: "tool_name", effect: "include", applicationId: null, connectionId: null,
+ catalogEntryId: null, toolName: "get_repository", riskLevel: null, conditions: null }],
+ }] });
+ api.tools.listPolicies.mockResolvedValue({ policies: [{ id: "policy", name: "Read", description: null, policyType: "allow",
+ priority: 100, enabled: true, selectors: { agentIds: [agentId] }, conditions: null, config: {} }] });
+ const saved = await snapshot();
+ expect(planAgentConfiguration(saved, roundTrip(saved.document))).toEqual([]);
+ const doc = copy(saved.document); doc.spec.tools!.profiles = []; doc.spec.tools!.policies = [];
+ await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
+ expect(api.tools.unbindProfile).toHaveBeenCalledWith("company", "profile", { targetType: "agent", targetId: agentId });
+ expect(api.tools.deletePolicy).toHaveBeenCalledWith("company", "policy");
+ expect(api.tools.updateProfile).not.toHaveBeenCalled();
+ });
+
+ it("switches instruction locations without overwriting files at the destination", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.instructions!.rootPath = "/workspace/new"; doc.spec.instructions!.mode = "external";
+ api.agents.updateInstructionsBundle.mockResolvedValue({ files: [file] });
+ api.agents.instructionsFile.mockResolvedValueOnce(file).mockResolvedValueOnce({ ...file, content: "Destination instructions" });
+ const result = await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
+ expect(api.agents.updateInstructionsBundle).toHaveBeenCalledWith(agentId, { mode: "external", rootPath: "/workspace/new", entryFile: "AGENTS.md" }, "company");
+ expect(api.agents.saveInstructionsFile).not.toHaveBeenCalled();
+ expect(api.agents.deleteInstructionsFile).not.toHaveBeenCalled();
+ expect(result.document.spec.instructions!.files["AGENTS.md"]).toBe("Destination instructions");
+ });
+
+ it("rejects a location change combined with file edits before applying an unrelated core edit", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.title = "Engineer"; doc.spec.instructions!.entryFile = "NEW.md";
+ doc.spec.instructions!.files["NEW.md"] = "New instructions";
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("location or entry file first");
+ expect(api.agents.update).not.toHaveBeenCalled();
+ expect(api.agents.updateInstructionsBundle).not.toHaveBeenCalled();
+ });
+
+ it("rejects ignored managed-root changes and impossible task-assignment permissions", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.instructions!.rootPath = "/ignored/root";
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("assigned by Paperclip");
+ doc.spec.instructions = saved.document.spec.instructions;
+ doc.spec.permissions!.canCreateAgents = true;
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("task assignment authority");
+ expect(api.agents.updatePermissions).not.toHaveBeenCalled();
+ });
+
+ it("creates a secret and binds its returned ID without exporting the value", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.secrets!.create = [{ secret: { name: "Token", value: "write-only-value", provider: "local_encrypted" }, bind: { env: ["TOKEN"], api: ["SERVICE"] } }];
+ const result = await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
+ expect(api.secrets.create).toHaveBeenCalledWith("company", expect.objectContaining({ value: "write-only-value" }));
+ expect(api.agents.update).toHaveBeenCalledWith(agentId, expect.objectContaining({
+ adapterConfig: expect.objectContaining({ env: { TOKEN: { type: "secret_ref", secretId: "new-secret", version: "latest" } },
+ "access.SERVICE": { type: "secret_ref", secretId: "new-secret", version: "latest" } }),
+ expectedConfigurationRevision: agent.configurationRevision,
+ }), "company");
+ expect(JSON.stringify(result.document)).not.toContain("write-only-value");
+ });
+
+ it("rejects nested schema typos and unsupported channel creation before writes", async () => {
+ const saved = await snapshot();
+ const doc = copy(saved.document);
+ Object.assign(doc.spec.skills![0] as object, { typo: true });
+ expect(() => roundTrip(doc)).toThrow("spec.skills.0");
+ doc.spec.skills = saved.document.spec.skills;
+ doc.spec.channelOperations!.create = [{ provider: "telegram", slackSetupMethod: "manual" }];
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("Slack app details");
+ expect(api.channels.create).not.toHaveBeenCalled();
+ });
+
+ it("saves each domain through its owning API and preserves other agents' installations", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.title = "Engineer";
+ doc.spec.instructions!.files["AGENTS.md"] = "# Revised\n";
+ doc.spec.skills = [];
+ doc.spec.permissions!.canCreateSkills = false;
+ doc.spec.tools!.connections[0].installed = false;
+ doc.spec.channels![0].settings.allowUnlinkedPeople = true;
+ doc.spec.channels![0].resources = [];
+ doc.spec.apiKeys = { create: [{ name: "Automation", scope: { kind: "standard" } }], revoke: ["key"] };
+ doc.spec.channelOperations!.revokeIdentityLinks = [{ endpointId: "channel", principalId: "principal" }];
+ doc.spec.secrets!.rotate = [{ id: "secret", input: { value: "replacement" } }];
+ doc.spec.secrets!.proposals = [{ id: "proposal", decision: "reject", reason: "Wrong account" }];
+ await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
+ expect(api.agents.update).toHaveBeenCalledExactlyOnceWith(agentId, { title: "Engineer", expectedConfigurationRevision: agent.configurationRevision }, "company");
+ expect(api.agents.saveInstructionsFile).toHaveBeenCalledWith(agentId, { path: "AGENTS.md", content: "# Revised\n", baseHash: "hash", baseRevisionId: "revision-file" }, "company");
+ expect(api.agents.syncSkills).toHaveBeenCalledWith(agentId, [], "replace", "company");
+ expect(api.tools.putConnectionInstalls).toHaveBeenCalledWith("connection", [{ targetType: "agent", targetId: "another-agent" }]);
+ expect(api.channels.update).toHaveBeenCalledExactlyOnceWith("channel", { allowUnlinkedPeople: true });
+ expect(api.channels.updateResources).toHaveBeenCalledWith("channel", [{ id: resourceId, enabled: false, executionDefaults: null }]);
+ expect(api.channels.revokeLink).toHaveBeenCalledWith("channel", "principal");
+ expect(api.agents.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ canCreateSkills: false }), "company");
+ expect(api.secrets.rotate).toHaveBeenCalledWith("secret", { value: "replacement" });
+ expect(api.secrets.rejectProposal).toHaveBeenCalledWith("company", "proposal", { reason: "Wrong account" });
+ expect(api.agents.revokeKey).toHaveBeenCalledWith(agentId, "key", "company");
+ expect(receipt).toHaveBeenCalledWith({ label: "API key: Automation", id: "new-key", token: "one-time-token" });
+ });
+
+ it("requires explicit clears for nested PATCH fields", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.title = "Engineer";
+ delete doc.spec.channels![0].resources[0].enabled;
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow(".enabled explicit");
+ expect(api.agents.update).not.toHaveBeenCalled();
+ });
+
+ it("requires explicit consent for shared profile changes and still supports binding without changing them", async () => {
+ const profile = { id: "profile", profileKey: "shared", name: "Shared", description: null,
+ status: "active", defaultAction: "deny", metadata: null, entries: [],
+ bindings: [{ targetType: "agent", targetId: "another-agent", priority: 100, metadata: null }] };
+ api.tools.listProfiles.mockResolvedValue({ profiles: [profile] });
+ const saved = await snapshot(); const doc = copy(saved.document);
+ const configuration = { profileKey: "shared", name: "Shared", description: null, status: "active" as const, defaultAction: "deny" as const, metadata: null, entries: [] };
+ doc.spec.tools!.profiles = [{ profileId: "profile", priority: 100, metadata: null, configuration }];
+ await saveAgentConfiguration(saved, doc, receipt, progress);
+ expect(api.tools.bindProfile).toHaveBeenCalledTimes(1);
+ expect(api.tools.updateProfile).not.toHaveBeenCalled();
+ configuration.name = "Changed for everyone";
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("updateSharedDefinition: true");
+ doc.spec.tools!.profiles[0].updateSharedDefinition = true;
+ await saveAgentConfiguration(saved, doc, receipt, progress);
+ expect(api.tools.updateProfile).toHaveBeenCalledWith("profile", configuration);
+ delete (configuration as Partial).description;
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow(".description explicit");
+ });
+
+ it("rejects a newly selected profile whose definition changed after the editor loaded", async () => {
+ const profile = { id: "profile", profileKey: "unbound", name: "Unbound", description: null,
+ status: "active", defaultAction: "deny", metadata: null, entries: [], bindings: [] };
+ api.tools.listProfiles.mockResolvedValueOnce({ profiles: [profile] });
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.tools!.profiles = [{ profileId: "profile", priority: 100, metadata: null, configuration:
+ { profileKey: "unbound", name: "Unbound", description: null, status: "active", defaultAction: "deny", metadata: null, entries: [] } }];
+ api.tools.listProfiles.mockResolvedValue({ profiles: [{ ...profile, name: "Changed elsewhere" }] });
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("Tool profile profile changed");
+ expect(api.tools.updateProfile).not.toHaveBeenCalled();
+ expect(api.tools.bindProfile).not.toHaveBeenCalled();
+ });
+
+ it("rejects removing a channel while editing its settings or identity links", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.channels![0].settings.communicationInstructions = "New instructions";
+ doc.spec.channelOperations!.setup = [{ id: "channel", input: { action: "remove" } }];
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("edited and removed");
+ doc.spec.channels = []; doc.spec.channelOperations!.setup = [];
+ doc.spec.channelOperations!.revokeIdentityLinks = [{ endpointId: "channel", principalId: "principal" }];
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("removed and have its identities edited");
+ expect(api.channels.setup).not.toHaveBeenCalled();
+ expect(api.channels.update).not.toHaveBeenCalled();
+ });
+
+ it("rejects overlapping secret bindings and preserves a concurrently edited binding", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ const create = { secret: { name: "Token", value: "new-value", provider: "local_encrypted" as const }, bind: { env: ["TOKEN"], api: [] } };
+ doc.spec.secrets!.create = [create, { ...create, secret: { ...create.secret, name: "Other" } }];
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("bind.env contains duplicate");
+ doc.spec.secrets!.create = [create]; doc.spec.adapterConfig.env = { TOKEN: "competing-value" };
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("not both");
+ delete doc.spec.adapterConfig.env;
+ api.agents.getConfiguration.mockResolvedValueOnce(agent).mockResolvedValueOnce({ ...agent, adapterConfig: { ...agent.adapterConfig, env: { TOKEN: "someone-else" } } });
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("TOKEN changed");
+ expect(api.secrets.create).not.toHaveBeenCalled();
+ expect(api.agents.update).not.toHaveBeenCalled();
+ });
+
+ it("retains normalization warnings returned by writes after refreshing the saved snapshot", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document); doc.spec.title = "Engineer";
+ const warning = { code: "runner_option_ignored", field: "chrome", message: "Chrome is ignored" };
+ api.agents.update.mockResolvedValue({ ...agent, configurationWarnings: [warning] });
+ const result = await saveAgentConfiguration(saved, doc, receipt, progress);
+ expect(result.agent.configurationWarnings).toEqual([warning]);
+ });
+
+ it("checks the entire plan before making any writes", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.title = "Engineer"; doc.spec.apiKeys!.revoke = ["foreign-key"];
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("foreign-key");
+ expect(api.agents.update).not.toHaveBeenCalled(); expect(api.agents.getConfiguration).toHaveBeenCalledTimes(1);
+ });
+
+ it("rejects stale core, file content and edited channel settings before writes", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document); doc.spec.title = "Engineer";
+ api.agents.getConfiguration.mockResolvedValueOnce({ ...agent, configurationRevision: "b".repeat(64) });
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("configuration changed");
+ doc.spec.instructions!.files["AGENTS.md"] = "My change";
+ api.agents.instructionsFile.mockResolvedValueOnce({ ...file, content: "Someone else's change" });
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("instructions changed");
+ delete doc.spec.instructions;
+ doc.spec.channels![0].settings.communicationInstructions = "Be concise";
+ api.channels.get.mockResolvedValueOnce({ ...channel, allowUnlinkedPeople: true });
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("channels changed");
+ expect(api.agents.update).not.toHaveBeenCalled();
+ });
+
+ it("does not undo a concurrent change to an unedited resource section", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document); doc.spec.title = "Engineer";
+ api.channels.get.mockResolvedValue({ ...channel, allowUnlinkedPeople: true });
+ await saveAgentConfiguration(saved, doc, receipt, progress);
+ expect(api.agents.update).toHaveBeenCalledTimes(1); expect(api.channels.update).not.toHaveBeenCalled();
+ });
+
+ it("reports partial progress without retrying or losing a newly created key", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.title = "Engineer";
+ doc.spec.apiKeys!.create = [{ name: "Automation", scope: { kind: "standard" } }, { name: "Second", scope: { kind: "standard" } }];
+ api.agents.createKey.mockResolvedValueOnce({ id: "new-key", name: "Automation", token: "one-time-token" }).mockRejectedValueOnce(new Error("Network lost"));
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow(ConfigurationSaveInterrupted);
+ expect(receipt).toHaveBeenCalledWith(expect.objectContaining({ token: "one-time-token" }));
+ expect(api.agents.update).toHaveBeenCalledTimes(1); expect(api.agents.createKey).toHaveBeenCalledTimes(2);
+ });
+
+ it("requires revision restore to be the only change and never replays history", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.revisions!.restore = "revision"; doc.spec.title = "Engineer";
+ await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("own save");
+ doc.spec.title = null;
+ await saveAgentConfiguration(saved, doc, receipt, progress);
+ expect(api.agents.rollbackConfigRevision).toHaveBeenCalledWith(agentId, "revision", "company");
+ expect(api.agents.update).not.toHaveBeenCalled();
+ });
+
+ it("rejects edits to observed context and changes to organization-wide installs", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.observed!.apiKeys = [];
+ expect(() => planAgentConfiguration(saved, doc)).toThrow("observed is read-only");
+ doc.observed = saved.document.observed;
+ saved.connections[0].installs = [{ targetType: "company", targetId: "company" }] as typeof saved.connections[0]["installs"];
+ doc.spec.tools!.connections[0].installed = false;
+ expect(() => planAgentConfiguration(saved, doc)).toThrow("organization-wide");
+ });
+
+ it("creates profiles, scoped policies and channel setup using native contracts", async () => {
+ const saved = await snapshot(); const doc = copy(saved.document);
+ doc.spec.tools!.profiles = [{ profileId: null, priority: 10, metadata: null, configuration: { name: "Read", profileKey: "read", status: "active", defaultAction: "deny", entries: [] } }];
+ doc.spec.tools!.policies = [{ id: null, configuration: { name: "Safe", policyType: "allow", priority: 10, enabled: true, selectors: {}, conditions: null, config: {} } }];
+ doc.spec.channelOperations!.setup = [{ id: "channel", input: { action: "pause" } }];
+ await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
+ expect(api.tools.createProfile).toHaveBeenCalledWith("company", expect.objectContaining({ profileKey: "read" }));
+ expect(api.tools.bindProfile).toHaveBeenCalledWith("company", "new-profile", { targetType: "agent", targetId: agentId, priority: 10, metadata: null });
+ expect(api.tools.createPolicy).toHaveBeenCalledWith("company", expect.objectContaining({ selectors: { agentIds: [agentId] } }));
+ expect(api.channels.setup).toHaveBeenCalledWith("channel", { action: "pause" });
+ });
+});
diff --git a/ui/src/lib/agent-configuration/save.ts b/ui/src/lib/agent-configuration/save.ts
new file mode 100644
index 00000000000..f60bce0490b
--- /dev/null
+++ b/ui/src/lib/agent-configuration/save.ts
@@ -0,0 +1,208 @@
+import { agentConfigurationDocumentSchema, type AgentConfigurationDocument, type AgentConfigurationWarning } from "@paperclipai/shared";
+import { agentsApi } from "../../api/agents";
+import { secretsApi } from "../../api/secrets";
+import { agentConfigurationPatch } from "../agent-configuration-yaml";
+import { equal, loadAgentConfiguration, ownedAdapterKeys, type AgentConfigurationSnapshot } from "./load";
+import { planToolChanges } from "./tools";
+import { planChannelChanges } from "./channels";
+import { requireItem, unique, type ConfigurationStep, type AddConfigurationStep } from "./steps";
+
+export interface ConfigurationReceipt { label: string; id: string; token?: string }
+export class ConfigurationSaveInterrupted extends Error {
+ constructor(label: string, completed: string[], cause: unknown) {
+ super(`${completed.length ? `Saved: ${completed.join(", ")}. ` : ""}Could not complete ${label}: ${cause instanceof Error ? cause.message : "Request failed"}. Some changes may have been saved. Reload saved before saving again.`);
+ }
+}
+
+function planInstructions(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep) {
+ const next = document.spec.instructions;
+ if (!next) return;
+ const previous = snapshot.document.spec.instructions!;
+ if (equal(previous, next)) return;
+ if (!snapshot.bundle.editable && !next.mode) throw new Error("Choose managed or external instructions mode before adding files.");
+ if (next.mode === null && previous.mode !== null) throw new Error("Choose managed or external instructions mode.");
+ if (next.mode === "external" && !next.rootPath?.startsWith("/")) throw new Error("External instructions require an absolute rootPath.");
+ if (Object.hasOwn(previous.files, next.entryFile) && !Object.hasOwn(next.files, next.entryFile)) throw new Error("The instructions entry file cannot be removed.");
+ for (const path of Object.keys(next.files)) {
+ const existing = snapshot.bundle.files.find(file => file.path === path);
+ if (existing && (!existing.editable || existing.binary || existing.virtual)) throw new Error(`Instruction file ${path} cannot be edited as text.`);
+ }
+ const bundleChanged = previous.mode !== next.mode || previous.rootPath !== next.rootPath || previous.entryFile !== next.entryFile;
+ if (bundleChanged) {
+ if (!equal(previous.files, next.files)) throw new Error("Save the instructions location or entry file first, then edit the files loaded from that location.");
+ if (next.mode === "managed" && previous.mode === "managed" && next.rootPath !== previous.rootPath) {
+ throw new Error("The managed instructions root is assigned by Paperclip. Choose external mode to use another folder.");
+ }
+ add("Instructions location (load the destination files)", () => agentsApi.updateInstructionsBundle(snapshot.agent.id, {
+ ...(next.mode ? { mode: next.mode } : {}), rootPath: next.rootPath, entryFile: next.entryFile,
+ }, snapshot.agent.companyId));
+ return;
+ }
+ const currentFiles = snapshot.files;
+ for (const [path, content] of Object.entries(next.files)) {
+ if (previous.files[path] === content) continue;
+ add(`Instructions: ${path}`, () => {
+ const file = currentFiles.get(path);
+ if (file?.content === content) return Promise.resolve();
+ return agentsApi.saveInstructionsFile(snapshot.agent.id, {
+ path, content, baseHash: file?.contentHash ?? null, baseRevisionId: file?.revision?.id ?? null,
+ }, snapshot.agent.companyId);
+ });
+ }
+ for (const path of Object.keys(previous.files)) {
+ if (Object.hasOwn(next.files, path)) continue;
+ add(`Remove instruction file: ${path}`, async () => {
+ const file = currentFiles.get(path);
+ if (!file) return;
+ return agentsApi.deleteInstructionsFile(snapshot.agent.id, path, snapshot.agent.companyId, file.contentHash);
+ });
+ }
+}
+
+function planSecrets(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep, receipt: (value: ConfigurationReceipt) => void) {
+ const next = document.spec.secrets;
+ if (!next) return;
+ const { id, companyId } = snapshot.agent;
+ unique(next.rotate.map(row => row.id), "secrets.rotate");
+ unique(next.update.map(row => row.id), "secrets.update");
+ unique(next.proposals.map(row => row.id), "secrets.proposals");
+ unique(next.create.map(row => row.secret.key ?? row.secret.name), "secrets.create");
+ for (const operation of [...next.rotate, ...next.update]) requireItem(snapshot.secrets, secret => secret.id === operation.id, `Secret ${operation.id}`);
+ unique(next.create.flatMap(operation => operation.bind.env), "secrets.create.bind.env");
+ unique(next.create.flatMap(operation => operation.bind.api), "secrets.create.bind.api");
+ const env = (config: Record) => config.env && typeof config.env === "object" && !Array.isArray(config.env) ? config.env as Record : {};
+ for (const operation of next.create) {
+ for (const key of operation.bind.env) {
+ if (!equal(env(document.spec.adapterConfig)[key], env(snapshot.document.spec.adapterConfig)[key])) throw new Error(`Set env.${key} either in adapterConfig or secrets.create.bind, not both in one save.`);
+ }
+ for (const alias of operation.bind.api) {
+ if (!equal(document.spec.adapterConfig[`access.${alias}`], snapshot.document.spec.adapterConfig[`access.${alias}`])) throw new Error(`Set access.${alias} either in adapterConfig or secrets.create.bind, not both in one save.`);
+ }
+ const assertBindingsCurrent = (config: Record) => {
+ for (const key of operation.bind.env) if (!equal(env(config)[key], env(snapshot.document.spec.adapterConfig)[key])) throw new Error(`env.${key} changed before secret binding. Reload saved before saving.`);
+ for (const alias of operation.bind.api) if (!equal(config[`access.${alias}`], snapshot.document.spec.adapterConfig[`access.${alias}`])) throw new Error(`access.${alias} changed before secret binding. Reload saved before saving.`);
+ };
+ add(`Create secret: ${operation.secret.name}`, async () => {
+ if (operation.bind.env.length || operation.bind.api.length) assertBindingsCurrent((await agentsApi.getConfiguration(id, companyId)).adapterConfig);
+ const created = await secretsApi.create(companyId, operation.secret);
+ receipt({ label: `Secret: ${created.name}`, id: created.id });
+ if (!operation.bind.env.length && !operation.bind.api.length) return;
+ const current = await agentsApi.getConfiguration(id, companyId);
+ assertBindingsCurrent(current.adapterConfig);
+ const adapterConfig = { ...current.adapterConfig };
+ const binding = { type: "secret_ref", secretId: created.id, version: "latest" };
+ if (operation.bind.env.length) adapterConfig.env = { ...(adapterConfig.env as Record ?? {}),
+ ...Object.fromEntries(operation.bind.env.map(key => [key, binding])) };
+ for (const alias of operation.bind.api) adapterConfig[`access.${alias}`] = binding;
+ await agentsApi.update(id, { adapterConfig, replaceAdapterConfig: true, expectedConfigurationRevision: current.configurationRevision }, companyId);
+ });
+ }
+ for (const operation of next.rotate) add(`Rotate secret: ${operation.id}`, () => secretsApi.rotate(operation.id, operation.input));
+ for (const operation of next.update) add(`Update secret: ${operation.id}`, () => secretsApi.update(operation.id, operation.input));
+ for (const operation of next.proposals) {
+ requireItem(snapshot.proposals, proposal => proposal.id === operation.id && (proposal.target?.id === id || proposal.proposedBy.id === id), `Secret proposal ${operation.id}`);
+ if (operation.decision === "reject" && !operation.reason?.trim()) throw new Error("A rejected secret proposal requires a reason.");
+ add(`Secret proposal: ${operation.id}`, () => operation.decision === "approve"
+ ? secretsApi.approveProposal(companyId, operation.id)
+ : secretsApi.rejectProposal(companyId, operation.id, { reason: operation.reason! }));
+ }
+}
+
+export function planAgentConfiguration(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, receipt: (value: ConfigurationReceipt) => void = () => {}) {
+ const steps: ConfigurationStep[] = [];
+ const add: AddConfigurationStep = (label, apply) => { steps.push({ label, apply }); };
+ if (!equal(document.observed, snapshot.document.observed)) throw new Error("observed is read-only. Edit spec to change configuration.");
+ for (const key of ownedAdapterKeys) if (Object.hasOwn(document.spec.adapterConfig, key)) throw new Error(`Use the ${key === "paperclipSkillSync" ? "skills" : "instructions"} section instead of adapterConfig.${key}.`);
+ const adapterConfig = { ...document.spec.adapterConfig };
+ for (const key of ownedAdapterKeys) if (Object.hasOwn(snapshot.core.adapterConfig, key)) adapterConfig[key] = snapshot.core.adapterConfig[key];
+ const corePatch = agentConfigurationPatch({ ...document, spec: { ...document.spec, adapterConfig } }, snapshot.core);
+ if (Object.keys(corePatch).length) add("Agent configuration", () => agentsApi.update(snapshot.agent.id, {
+ ...corePatch, expectedConfigurationRevision: snapshot.core.configurationRevision,
+ }, snapshot.agent.companyId));
+ if (document.spec.permissions && !document.spec.permissions.canAssignTasks && (document.spec.role === "ceo" || document.spec.permissions.canCreateAgents)) {
+ throw new Error("CEO agents and agents that can create agents also have task assignment authority. Set canAssignTasks to true.");
+ }
+ planInstructions(snapshot, document, add);
+ if (document.spec.skills && !equal(document.spec.skills, snapshot.document.spec.skills)) {
+ unique(document.spec.skills.map(skill => typeof skill === "string" ? skill : skill.key), "skills");
+ add("Skills and version pins", () => agentsApi.syncSkills(snapshot.agent.id, document.spec.skills!, "replace", snapshot.agent.companyId));
+ }
+ planToolChanges(snapshot, document, add);
+ planChannelChanges(snapshot, document, add);
+ planSecrets(snapshot, document, add, receipt);
+ if (document.spec.permissions && !equal(document.spec.permissions, snapshot.document.spec.permissions)) {
+ add("Permissions and trust", () => agentsApi.updatePermissions(snapshot.agent.id, { ...document.spec.permissions!,
+ authorizationPolicy: document.spec.permissions!.authorizationPolicy ?? {},
+ }, snapshot.agent.companyId));
+ }
+ const keys = document.spec.apiKeys;
+ if (keys) {
+ unique(keys.revoke, "apiKeys.revoke");
+ unique(keys.create.map(key => key.name), "apiKeys.create");
+ for (const id of keys.revoke) {
+ const key = requireItem(snapshot.keys, key => key.id === id, `API key ${id}`);
+ if (!key.revokedAt) add(`Revoke API key: ${key.name}`, () => agentsApi.revokeKey(snapshot.agent.id, id, snapshot.agent.companyId));
+ }
+ for (const input of keys.create) add(`Create API key: ${input.name}`, async () => {
+ const key = await agentsApi.createKey(snapshot.agent.id, input.name, snapshot.agent.companyId, input.scope);
+ receipt({ label: `API key: ${key.name}`, id: key.id, token: key.token });
+ });
+ }
+ if (document.spec.revisions?.restore) {
+ const id = document.spec.revisions.restore;
+ requireItem(snapshot.revisions, revision => revision.id === id, `Revision ${id}`);
+ if (steps.length) throw new Error("Restore a revision in its own save, then edit the restored configuration.");
+ add("Restore configuration revision", () => agentsApi.rollbackConfigRevision(snapshot.agent.id, id, snapshot.agent.companyId));
+ }
+ return steps;
+}
+
+export async function saveAgentConfiguration(snapshot: AgentConfigurationSnapshot, input: AgentConfigurationDocument,
+ onReceipt: (receipt: ConfigurationReceipt) => void, onProgress: (label: string) => void) {
+ const document = agentConfigurationDocumentSchema.parse(input);
+ const initialPlan = planAgentConfiguration(snapshot, document);
+ if (!initialPlan.length) return snapshot;
+ const current = await loadAgentConfiguration(snapshot.agent.id, snapshot.agent.companyId);
+ if (current.core.configurationRevision !== snapshot.core.configurationRevision) throw new Error("Agent configuration changed while you were editing. Reload saved before saving.");
+ for (const key of ["permissions", "instructions", "skills", "tools", "channels"] as const) {
+ if (document.spec[key] !== undefined && !equal(document.spec[key], snapshot.document.spec[key]) && !equal(current.document.spec[key], snapshot.document.spec[key])) {
+ throw new Error(`${key} changed while you were editing. Reload saved before saving.`);
+ }
+ }
+ const spec = { ...document.spec };
+ for (const key of ["permissions", "instructions", "skills", "tools", "channels"] as const) {
+ if (equal(document.spec[key], snapshot.document.spec[key])) Object.assign(spec, { [key]: current.document.spec[key] });
+ }
+ for (const operation of [...(document.spec.secrets?.rotate ?? []), ...(document.spec.secrets?.update ?? [])]) {
+ const previous = snapshot.secrets.find(secret => secret.id === operation.id);
+ if (!equal(previous, current.secrets.find(secret => secret.id === operation.id))) throw new Error(`Secret ${operation.id} changed. Reload saved before saving.`);
+ }
+ for (const profile of document.spec.tools?.profiles ?? []) {
+ if (!profile.profileId) continue;
+ const originalRow = snapshot.document.spec.tools?.profiles.find(value => value.profileId === profile.profileId);
+ if (originalRow && equal(originalRow, profile)) continue;
+ const before = snapshot.profiles.find(value => value.id === profile.profileId);
+ const fresh = current.profiles.find(value => value.id === profile.profileId);
+ if (!equal(before, fresh)) throw new Error(`Tool profile ${profile.profileId} changed while you were editing. Reload saved before saving.`);
+ }
+ const steps = planAgentConfiguration(current, { ...document, spec, observed: current.document.observed }, onReceipt);
+ const completed: string[] = [];
+ const warnings: AgentConfigurationWarning[] = [];
+ for (const step of steps) {
+ onProgress(step.label);
+ try {
+ const result = await step.apply();
+ if (result && typeof result === "object" && "configurationWarnings" in result && Array.isArray(result.configurationWarnings)) {
+ warnings.push(...result.configurationWarnings as AgentConfigurationWarning[]);
+ }
+ completed.push(step.label);
+ }
+ catch (cause) { throw new ConfigurationSaveInterrupted(step.label, completed, cause); }
+ }
+ try {
+ const refreshed = await loadAgentConfiguration(snapshot.agent.id, snapshot.agent.companyId);
+ if (warnings.length) refreshed.agent = { ...refreshed.agent, configurationWarnings: warnings };
+ return refreshed;
+ }
+ catch (cause) { throw new ConfigurationSaveInterrupted("refreshing the saved configuration", completed, cause); }
+}
diff --git a/ui/src/lib/agent-configuration/steps.ts b/ui/src/lib/agent-configuration/steps.ts
new file mode 100644
index 00000000000..cd4d016922b
--- /dev/null
+++ b/ui/src/lib/agent-configuration/steps.ts
@@ -0,0 +1,18 @@
+export interface ConfigurationStep { label: string; apply: () => Promise }
+export type AddConfigurationStep = (label: string, apply: ConfigurationStep["apply"]) => void;
+
+export function unique(values: string[], field: string) {
+ if (new Set(values).size !== values.length) throw new Error(`${field} contains duplicate entries.`);
+}
+
+export function requireItem(items: readonly T[], matches: (item: T) => boolean, label: string): T {
+ const item = items.find(matches);
+ if (!item) throw new Error(`${label} is not in this agent’s saved configuration. Reload before editing it.`);
+ return item;
+}
+
+export function requireExplicitFields(before: object, after: object, path: string) {
+ for (const [key, value] of Object.entries(before)) {
+ if (value !== undefined && !Object.hasOwn(after, key)) throw new Error(`Keep ${path}.${key} explicit; use null, false, an empty string, or an empty list to clear it where supported.`);
+ }
+}
diff --git a/ui/src/lib/agent-configuration/tools.ts b/ui/src/lib/agent-configuration/tools.ts
new file mode 100644
index 00000000000..0e12102e360
--- /dev/null
+++ b/ui/src/lib/agent-configuration/tools.ts
@@ -0,0 +1,70 @@
+import type { AgentConfigurationDocument } from "@paperclipai/shared";
+import { toolsApi } from "../../api/tools";
+import { equal, policyAgentIds, profileConfiguration, type AgentConfigurationSnapshot } from "./load";
+import { requireItem, requireExplicitFields, unique, type AddConfigurationStep } from "./steps";
+
+export function planToolChanges(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep) {
+ const next = document.spec.tools;
+ if (!next) return;
+ const before = snapshot.document.spec.tools!;
+ const { id: agentId, companyId } = snapshot.agent;
+ unique(next.connections.map(row => row.connectionId), "tools.connections");
+ unique(next.profiles.flatMap(row => row.profileId ? [row.profileId] : []), "tools.profiles");
+ unique(next.policies.flatMap(row => row.id ? [row.id] : []), "tools.policies");
+ for (const row of next.connections) requireItem(snapshot.connections, connection => connection.id === row.connectionId, `Connection ${row.connectionId}`);
+ for (const previous of before.connections) {
+ const installed = next.connections.find(row => row.connectionId === previous.connectionId)?.installed ?? false;
+ if (installed === previous.installed) continue;
+ const connection = snapshot.connections.find(row => row.id === previous.connectionId)!;
+ if (connection.installs?.some(row => row.targetType === "company")) throw new Error(`${connection.name} is installed organization-wide. Its inherited installation cannot be overridden for one agent.`);
+ add(`Tool installation: ${connection.name}`, async () => {
+ const current = await toolsApi.getConnectionInstalls(connection.id);
+ if (current.installs.some(row => row.targetType === "company") || current.installs.some(row => row.targetType === "agent" && row.targetId === agentId) !== previous.installed) {
+ throw new Error(`Installation for ${connection.name} changed. Reload before saving.`);
+ }
+ const installs = current.installs.filter(row => !(row.targetType === "agent" && row.targetId === agentId))
+ .map(({ targetType, targetId }) => ({ targetType, targetId }));
+ if (installed) installs.push({ targetType: "agent", targetId: agentId });
+ return toolsApi.putConnectionInstalls(connection.id, installs);
+ });
+ }
+ for (const row of next.profiles) {
+ const existing = row.profileId ? requireItem(snapshot.profiles, profile => profile.id === row.profileId, `Profile ${row.profileId}`) : null;
+ const previous = before.profiles.find(profile => profile.profileId === row.profileId);
+ if (previous && equal({ ...previous, updateSharedDefinition: false }, { ...row, updateSharedDefinition: false })) continue;
+ if (existing) {
+ requireExplicitFields(profileConfiguration(existing), row.configuration, `tools.profiles.${row.profileId}.configuration`);
+ const shared = existing.bindings.some(binding => binding.targetType !== "agent" || binding.targetId !== agentId);
+ if (shared && !equal(profileConfiguration(existing), row.configuration) && !row.updateSharedDefinition) {
+ throw new Error(`Profile ${existing.name} is shared with other targets. Set updateSharedDefinition: true to change its shared definition, or create a separate profile with profileId: null.`);
+ }
+ }
+ add(`Tool profile${row.updateSharedDefinition ? " (shared definition)" : ""}: ${row.configuration.name}`, async () => {
+ let profileId = row.profileId;
+ if (!profileId) profileId = (await toolsApi.createProfile(companyId, row.configuration)).id;
+ else if (existing && !equal(profileConfiguration(existing), row.configuration)) await toolsApi.updateProfile(profileId, row.configuration);
+ return toolsApi.bindProfile(companyId, profileId, { targetType: "agent", targetId: agentId, priority: row.priority, metadata: row.metadata });
+ });
+ }
+ for (const previous of before.profiles) {
+ if (!next.profiles.some(row => row.profileId === previous.profileId)) add(`Unbind tool profile: ${previous.configuration.name}`,
+ () => toolsApi.unbindProfile(companyId, previous.profileId!, { targetType: "agent", targetId: agentId }));
+ }
+ for (const row of next.policies) {
+ const previous = row.id ? requireItem(before.policies, policy => policy.id === row.id, `Agent policy ${row.id}`) : null;
+ if (previous) requireExplicitFields(previous.configuration, row.configuration, `tools.policies.${row.id}.configuration`);
+ const agents = policyAgentIds(row.configuration);
+ if (agents.some(id => id !== agentId)) throw new Error("tools.policies must be scoped only to this agent.");
+ const selectors: Record = { ...row.configuration.selectors, agentIds: [agentId] };
+ delete selectors.agentId;
+ const configuration = { ...row.configuration, selectors };
+ if (previous && equal(previous.configuration, row.configuration)) continue;
+ add(`Tool policy: ${configuration.name}`, () => row.id
+ ? toolsApi.updatePolicy(companyId, row.id, configuration)
+ : toolsApi.createPolicy(companyId, configuration));
+ }
+ for (const previous of before.policies) {
+ if (!next.policies.some(row => row.id === previous.id)) add(`Remove tool policy: ${previous.configuration.name}`,
+ () => toolsApi.deletePolicy(companyId, previous.id!));
+ }
+}
diff --git a/ui/src/pages/AgentAdvancedTab.test.tsx b/ui/src/pages/AgentAdvancedTab.test.tsx
new file mode 100644
index 00000000000..f2a81500f97
--- /dev/null
+++ b/ui/src/pages/AgentAdvancedTab.test.tsx
@@ -0,0 +1,153 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { EditorView } from "@codemirror/view";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { appearanceForPalette, agentConfigurationSpec, type Agent } from "@paperclipai/shared";
+import { ConfigurationSaveInterrupted } from "../lib/agent-configuration/save";
+import { AgentAdvancedTab } from "./AgentAdvancedTab";
+
+const api = vi.hoisted(() => ({ load: vi.fn(), save: vi.fn() }));
+vi.mock("../lib/agent-configuration/load", async importOriginal => ({
+ ...await importOriginal(), loadAgentConfiguration: api.load,
+}));
+vi.mock("../lib/agent-configuration/save", async importOriginal => ({
+ ...await importOriginal(), saveAgentConfiguration: api.save,
+}));
+vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
+
+const agent = {
+ id: "11111111-1111-4111-8111-111111111111", companyId: "company", urlKey: "codie", name: "Codie",
+ role: "engineer", title: null, icon: null, appearance: appearanceForPalette("bubblegum-sky"), reportsTo: null,
+ capabilities: null, adapterType: "process", adapterConfig: { command: "echo" }, runtimeConfig: {},
+ defaultEnvironmentId: null, budgetMonthlyCents: 0, metadata: null, configurationRevision: "a".repeat(64),
+ status: "idle", spentMonthlyCents: 0, pauseReason: null, pausedAt: null,
+ permissions: { canCreateAgents: false }, lastHeartbeatAt: null, createdAt: new Date(), updatedAt: new Date(),
+} satisfies Agent & { configurationRevision: string };
+
+function snapshot(value = agent) {
+ return { agent: value, core: value, document: { apiVersion: "paperclip/v1", kind: "Agent", spec: agentConfigurationSpec(value) } };
+}
+
+describe("Advanced agent configuration", () => {
+ let container: HTMLDivElement, root: Root, client: QueryClient;
+ const dirty = vi.fn(), onSaved = vi.fn();
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} unobserve() {} });
+ api.load.mockResolvedValue(snapshot());
+ container = document.createElement("div"); document.body.appendChild(container);
+ root = createRoot(container);
+ client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ });
+ afterEach(async () => { await act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
+ async function settle(assertion: () => void) {
+ for (let i = 0; i < 30; i++) {
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 5)); });
+ try { assertion(); return; } catch (error) { if (i === 29) throw error; }
+ }
+ }
+ async function render() {
+ await act(() => root.render(
+
+ ));
+ }
+ function editor() { return EditorView.findFromDOM(container.querySelector(".cm-content")!)!; }
+ async function edit(transform: (source: string) => string) {
+ await act(() => { const view = editor(); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: transform(view.state.doc.toString()) } }); });
+ }
+ function button(label: string) { return [...container.querySelectorAll("button")].find(button => button.textContent === label)!; }
+
+ it("retains an invalid or rejected draft and reloads the saved configuration explicitly", async () => {
+ api.save.mockRejectedValue(new Error("Agent configuration changed while you were editing."));
+ await render(); await settle(() => expect(editor()).toBeTruthy());
+ await edit(source => source.replace("budgetMonthlyCents: 0", "budgetMonthlyCents: -1"));
+ expect(button("Save changes").disabled).toBe(true);
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain("spec.budgetMonthlyCents");
+ await edit(source => source.replace("budgetMonthlyCents: -1", "budgetMonthlyCents: 500"));
+ await act(() => button("Save changes").click());
+ await settle(() => expect(container.textContent).toContain("configuration changed while you were editing"));
+ expect(editor().state.doc.toString()).toContain("budgetMonthlyCents: 500");
+ expect(api.save.mock.calls[0][1].spec.budgetMonthlyCents).toBe(500);
+ await act(() => button("Reload saved").click());
+ await settle(() => expect(editor().state.doc.toString()).toContain("budgetMonthlyCents: 0"));
+ expect(button("Save changes").disabled).toBe(true);
+ });
+
+ it("keeps the saved server result and uses its revision for the next edit", async () => {
+ api.save.mockResolvedValue(snapshot({ ...agent, name: "Codie Two", urlKey: "codie-two", configurationRevision: "b".repeat(64) }));
+ await render(); await settle(() => expect(editor()).toBeTruthy());
+ await edit(source => source.replace("name: Codie", "name: Codie Two"));
+ await act(() => button("Save changes").click());
+ await settle(() => expect(onSaved).toHaveBeenCalledTimes(1));
+ expect(button("Save changes").disabled).toBe(true);
+ expect(editor().state.doc.toString()).toContain("name: Codie Two");
+ await edit(source => source.replace("title: null", "title: Engineer"));
+ await act(() => button("Save changes").click());
+ await settle(() => expect(api.save).toHaveBeenCalledTimes(2));
+ expect(api.save.mock.calls[1][0].core.configurationRevision).toBe("b".repeat(64));
+ expect(api.save.mock.calls[1][1].spec.title).toBe("Engineer");
+ });
+
+ it("blocks retries after partial saves and keeps one-time credentials outside YAML", async () => {
+ api.save.mockImplementation(async (_snapshot, _document, onReceipt) => {
+ onReceipt({ id: "key", label: "Automation", token: "one-time-key" });
+ throw new ConfigurationSaveInterrupted("second key", ["first key"], new Error("Network lost"));
+ });
+ await render(); await settle(() => expect(editor()).toBeTruthy());
+ await edit(source => source.replace("title: null", "title: Engineer"));
+ await act(() => button("Save changes").click());
+ await settle(() => expect(container.textContent).toContain("Reload saved before saving again"));
+ expect(button("Save changes").disabled).toBe(true);
+ expect((container.querySelector('input[aria-label="Automation token"]') as HTMLInputElement).value).toBe("one-time-key");
+ expect(editor().state.doc.toString()).not.toContain("one-time-key");
+ await edit(source => source.replace("title: Engineer", "title: Lead"));
+ expect(button("Save changes").disabled).toBe(true);
+ await act(() => button("Reload saved").click());
+ await settle(() => expect(editor().state.doc.toString()).toContain("title: null"));
+ await edit(source => source.replace("title: null", "title: Lead"));
+ expect(button("Save changes").disabled).toBe(false);
+ });
+
+ it("previews changes and sends only one save for a double click", async () => {
+ let complete!: (value: ReturnType) => void;
+ api.save.mockImplementation(() => new Promise(resolve => { complete = resolve; }));
+ await render(); await settle(() => expect(editor()).toBeTruthy());
+ await edit(source => source.replace("title: null", "title: Engineer"));
+ expect(container.querySelector("summary")?.textContent).toBe("1 change to apply");
+ expect(container.querySelector("details")?.textContent).toContain("Agent configuration");
+ const view = editor();
+ await act(() => { button("Save changes").click(); button("Save changes").click(); });
+ await settle(() => expect(api.save).toHaveBeenCalledTimes(1));
+ expect(button("Reload saved").disabled).toBe(true);
+ await act(async () => complete(snapshot()));
+ await settle(() => expect(button("Save changes").disabled).toBe(true));
+ expect(editor()).toBe(view);
+ });
+
+ it("validates read-only sections before enabling Save", async () => {
+ api.load.mockResolvedValue({ ...snapshot(), document: { ...snapshot().document, observed: { apiKeys: [] } } });
+ await render(); await settle(() => expect(editor()).toBeTruthy());
+ await edit(source => source.replace("apiKeys: []", "apiKeys: [changed]"));
+ expect(button("Save changes").disabled).toBe(true);
+ expect(container.textContent).toContain("observed is read-only");
+ expect(api.save).not.toHaveBeenCalled();
+ });
+
+ it("keeps Save disabled for formatting-only changes", async () => {
+ await render(); await settle(() => expect(editor()).toBeTruthy());
+ await edit(source => `${source}\n# A local note\n`);
+ expect(button("Save changes").disabled).toBe(true);
+ expect(container.textContent).toContain("No configuration changes");
+ expect(api.save).not.toHaveBeenCalled();
+ });
+
+ it("shows a configuration read denial without rendering an empty editable document", async () => {
+ api.load.mockRejectedValue(new Error("Configuration access denied"));
+ await render(); await settle(() => expect(container.textContent).toContain("Configuration access denied"));
+ expect(container.querySelector(".cm-content")).toBeNull();
+ expect(api.save).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/src/pages/AgentAdvancedTab.tsx b/ui/src/pages/AgentAdvancedTab.tsx
new file mode 100644
index 00000000000..efd6bf629fc
--- /dev/null
+++ b/ui/src/pages/AgentAdvancedTab.tsx
@@ -0,0 +1,184 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { basicSetup } from "codemirror";
+import { Compartment, EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { yaml } from "@codemirror/lang-yaml";
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { tags } from "@lezer/highlight";
+import type { Agent } from "@paperclipai/shared";
+import { Button } from "../components/ui/button";
+import { useToastActions } from "../context/ToastContext";
+import { queryKeys } from "../lib/queryKeys";
+import { parseAgentConfigurationYaml, serializeAgentConfigurationDocument } from "../lib/agent-configuration-yaml";
+import { loadAgentConfiguration, type AgentConfigurationSnapshot } from "../lib/agent-configuration/load";
+import { ConfigurationSaveInterrupted, planAgentConfiguration, saveAgentConfiguration, type ConfigurationReceipt } from "../lib/agent-configuration/save";
+
+const yamlHighlightStyle = HighlightStyle.define([
+ { tag: tags.propertyName, class: "text-primary" },
+ { tag: tags.string, class: "text-foreground" },
+ { tag: [tags.bool, tags.number, tags.null], class: "text-muted-foreground" },
+ { tag: tags.keyword, class: "text-primary" },
+ { tag: tags.comment, class: "text-muted-foreground" },
+]);
+
+export interface AgentAdvancedTabProps {
+ agent: Agent;
+ onDirtyChange: (dirty: boolean) => void;
+ onSaved: (updated: Agent) => void;
+ onSavingChange?: (saving: boolean) => void;
+}
+
+export function AgentAdvancedTab(props: AgentAdvancedTabProps) {
+ const [initial, setInitial] = useState(null);
+ const query = useQuery({
+ queryKey: ["agent-configuration-yaml", props.agent.companyId, props.agent.id],
+ queryFn: () => loadAgentConfiguration(props.agent.id, props.agent.companyId),
+ refetchOnWindowFocus: false,
+ });
+ useEffect(() => {
+ if (!initial && query.data && !query.isFetching) setInitial(query.data);
+ }, [initial, query.data, query.isFetching]);
+ if (!initial) {
+ if (query.error) return
+
{query.error.message}
+
+
;
+ return Loading configuration…
;
+ }
+ return ;
+}
+
+function ConfigurationEditor({ agent, initial, onDirtyChange, onSaved, onSavingChange }: AgentAdvancedTabProps & { initial: AgentConfigurationSnapshot }) {
+ const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+ const [saved, setSaved] = useState(initial);
+ const [source, setSource] = useState(() => serializeAgentConfigurationDocument(initial.document));
+ const [error, setError] = useState(null);
+ const [reloadRequired, setReloadRequired] = useState(false);
+ const [progress, setProgress] = useState("");
+ const [receipts, setReceipts] = useState([]);
+ const operationPending = useRef(false);
+ const active = useRef(true);
+ useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
+ const original = useMemo(() => serializeAgentConfigurationDocument(saved.document), [saved]);
+ const dirty = source !== original;
+ const validation = useMemo(() => {
+ try {
+ const document = parseAgentConfigurationYaml(source);
+ return { document, changes: planAgentConfiguration(saved, document).map(step => step.label), error: null };
+ } catch (cause) { return { document: null, changes: [], error: cause instanceof Error ? cause.message : "Invalid YAML" }; }
+ }, [source, saved]);
+ useEffect(() => { onDirtyChange(dirty || reloadRequired); }, [dirty, reloadRequired, onDirtyChange]);
+ useEffect(() => () => onDirtyChange(false), [onDirtyChange]);
+
+ const save = useMutation({
+ mutationFn: async () => {
+ if (!validation.document) throw new Error(validation.error ?? "Invalid YAML");
+ if (reloadRequired) throw new Error("Reload saved before saving again.");
+ return saveAgentConfiguration(saved, validation.document,
+ receipt => { if (active.current) setReceipts(current => [...current, receipt]); },
+ label => { if (active.current) setProgress(label); });
+ },
+ retry: false,
+ onSuccess: configuration => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
+ queryClient.setQueryData(["agent-configuration-yaml", agent.companyId, agent.id], configuration);
+ if (!active.current) return;
+ setSaved(configuration);
+ setSource(serializeAgentConfigurationDocument(configuration.document));
+ setError(null);
+ onDirtyChange(false);
+ setReloadRequired(false);
+ onSaved(configuration.agent);
+ const warnings = configuration.agent.configurationWarnings;
+ pushToast({ title: "Agent saved", tone: warnings?.length ? "warn" : "success", body: warnings?.map(warning => warning.message).join("\n") });
+ },
+ onSettled: () => {
+ operationPending.current = false;
+ queryClient.invalidateQueries({ predicate: query => query.queryKey.some(part => part === agent.id || part === agent.companyId), refetchType: "none" });
+ },
+ onError: cause => {
+ if (!active.current) return;
+ setError(cause.message);
+ if (cause instanceof ConfigurationSaveInterrupted) setReloadRequired(true);
+ },
+ });
+ const reload = useMutation({
+ mutationFn: () => loadAgentConfiguration(agent.id, agent.companyId),
+ onSuccess: updated => {
+ queryClient.setQueryData(["agent-configuration-yaml", agent.companyId, agent.id], updated);
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
+ if (active.current) { setSaved(updated); setSource(serializeAgentConfigurationDocument(updated.document)); setError(null); setReloadRequired(false); onSaved(updated.agent); }
+ },
+ onSettled: () => { operationPending.current = false; },
+ onError: cause => setError(cause.message),
+ });
+ const busy = save.isPending || reload.isPending;
+ useEffect(() => { onSavingChange?.(busy); }, [busy, onSavingChange]);
+ useEffect(() => () => onSavingChange?.(false), [onSavingChange]);
+ return
+ {receipts.length > 0 &&
+ {receipts.map((receipt, index) =>
+
{receipt.label}
+ {receipt.token ? <>
+
Copy this key now. It won’t be shown again after leaving this page.
+
+ > :
{receipt.id}
}
+
)}
+
}
+
{ setSource(value); if (!reloadRequired) setError(null); }} readOnly={busy} />
+ {dirty && validation.changes.length > 0 &&
+ {validation.changes.length} {validation.changes.length === 1 ? "change" : "changes"} to apply
+ {validation.changes.map((label, index) => - {label}
)}
+ Changes save in this order. If one fails, earlier changes remain saved.
+ }
+ {(validation.error || error) && {validation.error || error}
}
+
+ ;
+}
+
+function AgentYamlEditor({ value, onChange, readOnly }: { value: string; onChange: (value: string) => void; readOnly: boolean }) {
+ const container = useRef(null);
+ const editor = useRef(null);
+ const editability = useRef(new Compartment());
+ const change = useRef(onChange);
+ change.current = onChange;
+ const current = useRef(value);
+ current.current = value;
+ useEffect(() => {
+ if (!container.current) return;
+ const view = new EditorView({
+ parent: container.current,
+ state: EditorState.create({ doc: current.current, extensions: [
+ basicSetup, yaml(),
+ syntaxHighlighting(yamlHighlightStyle),
+ EditorView.contentAttributes.of({ "aria-label": "Agent YAML configuration", spellcheck: "false" }),
+ editability.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
+ EditorView.updateListener.of(update => { if (update.docChanged) change.current(update.state.doc.toString()); }),
+ ] }),
+ });
+ editor.current = view;
+ return () => { view.destroy(); editor.current = null; };
+ }, []);
+ useEffect(() => {
+ editor.current?.dispatch({ effects: editability.current.reconfigure([
+ EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly),
+ ]) });
+ }, [readOnly]);
+ useEffect(() => {
+ const view = editor.current;
+ if (view && view.state.doc.toString() !== value) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
+ }, [value]);
+ return ;
+}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index 02dbb091e76..49f53a7af89 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -14,7 +14,7 @@ import { AgentCharacter } from "../components/AgentCharacter";
import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { usePageVisibility } from "../lib/page-visibility";
-import { useCallback, useEffect, useMemo, useState, useRef } from "react";
+import { lazy, Suspense, useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
@@ -155,6 +155,8 @@ import {
type AgentDetailView,
} from "./agent-detail-navigation";
+const AgentAdvancedTab = lazy(() => import("./AgentAdvancedTab").then(module => ({ default: module.AgentAdvancedTab })));
+
const runStatusIcons: Record = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
@@ -821,8 +823,9 @@ export function AgentDetail() {
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const prepareAgentNavigation = useCallback(() => {
+ if (activeView === "advanced" && configSaving) return false;
return confirmAgentConfigNavigation(configDirty);
- }, [configDirty]);
+ }, [activeView, configDirty, configSaving]);
const agentQuery = useQuery({
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
@@ -979,6 +982,7 @@ export function AgentDetail() {
useEffect(() => {
if (!agent) return;
+ if (activeView === "advanced" && (configSaving || configDirty)) return;
if (urlRunId) {
if (routeAgentRef !== canonicalAgentRef) {
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
@@ -991,7 +995,7 @@ export function AgentDetail() {
navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true });
return;
}
- }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, location.search]);
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, configSaving, configDirty, legacyAuditSection, navigate, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -1301,7 +1305,7 @@ export function AgentDetail() {
companyId={resolvedCompanyId}
assignLabel="Assign Task"
showStatus={false}
- actionsDisabled={agentAction.isPending}
+ actionsDisabled={agentAction.isPending || (activeView === "advanced" && configSaving)}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
hasPendingNavigationChanges={configDirty}
@@ -1508,6 +1512,20 @@ export function AgentDetail() {
)}
+ {activeView === "advanced" && (
+ Loading configuration…
}>
+ {
+ const nextRef = agentRouteRef(updated);
+ const currentKey = [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null];
+ void queryClient.cancelQueries({ queryKey: currentKey, exact: true });
+ queryClient.setQueryData(currentKey, { ...agent, ...updated });
+ queryClient.setQueryData([...queryKeys.agents.detail(nextRef), lookupCompanyId ?? null], { ...agent, ...updated });
+ if (nextRef !== routeAgentRef) navigate(`/agents/${nextRef}/advanced`, { replace: true });
+ }} />
+
+ )}
+
{activeView === "run-detail" && (
{
"permissions",
"api-keys",
"revisions",
+ "advanced",
]);
});
it("canonicalizes legacy local paths", () => {
+ expect(parseAgentDetailView("advanced")).toBe("advanced");
+ expect(agentDetailHref("codie", "advanced")).toBe("/agents/codie/advanced");
expect(parseAgentDetailView("dashboard")).toBe("overview");
expect(parseAgentDetailView("configuration")).toBe("runtime");
expect(parseAgentDetailView("prompts")).toBe("instructions");
diff --git a/ui/src/pages/agent-detail-navigation.ts b/ui/src/pages/agent-detail-navigation.ts
index 39308751bf3..48d30e8f676 100644
--- a/ui/src/pages/agent-detail-navigation.ts
+++ b/ui/src/pages/agent-detail-navigation.ts
@@ -11,6 +11,7 @@ export type AgentDetailView =
| "permissions"
| "api-keys"
| "revisions"
+ | "advanced"
| "run-detail";
export type AgentLocalDetailView = Exclude;
@@ -42,6 +43,7 @@ export const AGENT_DETAIL_NAVIGATION: ReadonlyArray<{
{ value: "permissions", label: "Permissions / Trust" },
{ value: "api-keys", label: "API Keys" },
{ value: "revisions", label: "Revisions" },
+ { value: "advanced", label: "Advanced" },
],
},
] as const;
@@ -56,6 +58,7 @@ export function parseAgentDetailView(value: string | null): AgentLocalDetailView
if (value === "permissions" || value === "trust") return "permissions";
if (value === "api-keys" || value === "keys") return "api-keys";
if (value === "revisions" || value === "history") return "revisions";
+ if (value === "advanced") return "advanced";
return "overview";
}
diff --git a/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx b/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx
index b6da8355384..22eca87c84a 100644
--- a/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx
+++ b/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx
@@ -18,6 +18,7 @@ import {
confirmAgentConfigNavigation,
} from "@/pages/AgentDetail";
import { AgentSkillsTab } from "@/pages/agent-skills/AgentSkillsTab";
+import { AgentAdvancedTab } from "@/pages/AgentAdvancedTab";
import { AgentToolsTab } from "@/pages/AgentToolsTab";
import { AgentContextualSidebar } from "@/components/AgentContextualSidebar";
import { AgentActionButtons } from "@/components/AgentActionButtons";
@@ -45,6 +46,7 @@ import { storybookIssues } from "../../fixtures/paperclipData";
import "./settings.css";
const descriptions: Record = {
+ advanced: "Edit the agent’s configuration as YAML.",
overview: "A snapshot of Nova’s work, capabilities, and current setup.",
instructions: "The files that guide how your agent thinks and works.",
skills: "Choose the skills your agent brings to each task.",
@@ -275,7 +277,7 @@ function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
if (
dirty &&
(event.target as HTMLElement).closest("a") &&
- !confirmAgentConfigNavigation(true)
+ (saving || !confirmAgentConfigNavigation(true))
) {
event.preventDefault();
event.stopPropagation();
@@ -471,6 +473,9 @@ function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
{view === "revisions" && (
)}
+ {view === "advanced" && (
+ {}} />
+ )}
{(saveError || permissions.error) && (
diff --git a/ui/storybook/prototypes/agent-settings/fixtures.ts b/ui/storybook/prototypes/agent-settings/fixtures.ts
index c5de3920027..e9152f63c78 100644
--- a/ui/storybook/prototypes/agent-settings/fixtures.ts
+++ b/ui/storybook/prototypes/agent-settings/fixtures.ts
@@ -86,6 +86,8 @@ export function createSettingsFixtures(
outcome: TestOutcome = "pass",
saveFails = false,
) {
+ let configurationVersion = 0;
+ const configurationRevision = () => configurationVersion.toString(16).padStart(64, "0");
let agent: AgentDetail = {
...storybookHiredAgent,
id: ID,
@@ -117,6 +119,7 @@ export function createSettingsFixtures(
},
};
let desiredSkills = ["paperclip", "design-guide"];
+ let desiredSkillEntries = desiredSkills.map(key => ({ key, versionId: null as string | null }));
const files: Record = {
"AGENTS.md":
"# Nova\n\nYou are a product engineer. Build interfaces that are clear, useful, and reliable.\n\n## Working style\n\n- Read the task and relevant code before making changes.\n- Keep changes focused and preserve existing behavior.\n- Test the experience in the browser before handing it back.\n\nRead [workflow.md](workflow.md) for your delivery checklist.\n",
@@ -155,6 +158,7 @@ export function createSettingsFixtures(
supported: true,
mode: "ephemeral",
desiredSkills,
+ desiredSkillEntries,
warnings: [],
entries: desiredSkills.map((key) => ({
key,
@@ -331,6 +335,12 @@ export function createSettingsFixtures(
}
if (url.pathname === `/api/companies/${COMPANY}/tools/connections`)
return json({ connections: [connection()] });
+ if (url.pathname === `/api/companies/${COMPANY}/tools/profiles`)
+ return json({ profiles: [] });
+ if (url.pathname === `/api/companies/${COMPANY}/chat-endpoints`)
+ return json([]);
+ if (url.pathname === `/api/companies/${COMPANY}/secret-proposals`)
+ return json([]);
if (url.pathname === `/api/companies/${COMPANY}/tools/policies`)
return json({ policies: [] });
if (url.pathname.includes("/tools/profiles/effective/agents/"))
@@ -377,6 +387,11 @@ export function createSettingsFixtures(
{ status: 503 },
);
const patch = await payload();
+ if (patch.expectedConfigurationRevision && patch.expectedConfigurationRevision !== configurationRevision()) {
+ return Response.json({ error: "Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again." }, { status: 409 });
+ }
+ delete patch.expectedConfigurationRevision;
+ delete patch.replaceAdapterConfig;
const before = agent;
agent =
path === "/permissions"
@@ -391,11 +406,14 @@ export function createSettingsFixtures(
}
: { ...agent, ...patch };
recordRevision(before, patch);
- return json(agent);
+ configurationVersion++;
+ return json({ ...agent, configurationRevision: configurationRevision() });
}
if (!path) return json(agent);
+ if (path === "/configuration") return json({ ...agent, configurationRevision: configurationRevision() });
if (path === "/skills/sync") {
const data = await payload();
+ desiredSkillEntries = data.desiredSkills.map((skill: string | { key: string; versionId?: string }) => typeof skill === "string" ? { key: skill, versionId: null } : { ...skill, versionId: skill.versionId ?? null });
desiredSkills = data.desiredSkills.map(
(s: string | { key: string }) =>
typeof s === "string" ? s : s.key,
diff --git a/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx b/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx
index 17ff34527b5..9564c92a678 100644
--- a/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx
+++ b/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx
@@ -37,6 +37,7 @@ const meta = {
"permissions",
"api-keys",
"revisions",
+ "advanced",
],
},
adapterType: {
@@ -59,6 +60,13 @@ export default meta;
type Story = StoryObj;
export const Overview: Story = { args: { initialTab: "overview" } };
export const Instructions: Story = { args: { initialTab: "instructions" } };
+export const Advanced: Story = {
+ args: { initialTab: "advanced" },
+ parameters: { paperclip: {
+ capabilities: ["agents/instructions"], status: "current", implementation: "mixed", boundary: "fixtures",
+ notes: "Production YAML editor inside the settings preview shell. Mutable fixtures simulate saves and revision conflicts; server authorization and secret restoration require integration verification.",
+ } },
+};
export const Skills: Story = { args: { initialTab: "skills" } };
export const Runtime: Story = {
name: "Harness / Runtime",