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}

} +
+ +
+ {save.isPending ? `Saving: ${progress || "configuration"}…` : reload.isPending ? "Loading configuration…" : dirty ? validation.document && !validation.changes.length ? "No configuration changes" : "Unsaved changes" : ""} + +
+
+
; +} + +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",