mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:12:18 +01:00
feat: add a complete agent YAML configuration editor (#18036)
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/175/changes/paperclip/175 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators configure each agent through several settings tabs. > - Operators also need one complete, editable configuration document. > - The document must retain each resource service's access and validation rules. > - This pull request defines the `paperclip/v1` Agent YAML format and adds an Advanced editor. > - The editor previews changes, rejects stale core writes, and explains partial saves. > - Operators can edit agent settings in one place and verify the saved state after reload. ## Linked Issues or Issue Description Refs #2219. Related settings work: #10974. **Problem or motivation** Agent configuration spans profile, runtime, instructions, skills, tools, channels, permissions, secrets, keys, and revision history. Operators cannot inspect and edit those settings as one versioned YAML document. **Proposed solution** Add an Advanced agent tab with a YAML editor. Define a shared document schema. Read and write resources through their existing APIs. Separate editable settings, explicit commands, and read-only observed state. **Alternatives considered** A raw agent-row editor would omit related resources. A generic database update would bypass resource ownership. The editor therefore uses the existing resource contracts and records partial results when a later request fails. **Roadmap alignment** This extends the shipped agent configuration capability. It relates to easy AGENTS.md configuration, multi-harness teams, governed tool access, and secrets management. Stored credentials remain redacted. This format is an editable settings view; it does not add company portability or provider consent. ## What Changed - Add the `paperclip/v1` Agent document schema and format documentation. - Add the Advanced route, sidebar entry, YAML editor, validation, change summary, save progress, and reload recovery. - Cover core settings, instruction files, skill pins, tool installs/profiles/policies, channels, permissions, secret operations, API keys, and revision restore. - Add private-keyed configuration revisions checked under the agent write lock. Read the current key through its owner so file-based rotation does not require restarting the server. Preserve unchanged secret markers at their original paths. - Retain drafts and returned credentials after partial saves. Require reload before retrying commands. Prevent duplicate submissions. Keep new key tokens visible through rename and disable saves with an empty change plan. - Send only changed channel settings. Require explicit intent for shared profile edits. Load destination instruction files when changing their location. - Update Storybook fixtures, verification maps, and regression tests. Add browser acceptance through real APIs and PostgreSQL. ## Verification - `pnpm -r typecheck` passes. - `pnpm build` passes. - The focused UI suite passes all 50 tests. The API/revision helper suites pass all 93 tests. - All 65 lifecycle tests pass with a dedicated PostgreSQL database. They exercise real row locks, redaction, revision history, audit, and company access. - All six browser acceptance cases pass. They check saved settings and clears, secret redaction, key creation/revocation, stale drafts, lost-response recovery, instruction relocation, revision restore, and key-token retention through rename. The tests use real resource APIs and storage. The MCP target uses the existing local provider fixture. No model run or provider login is claimed. - Signing-key regressions cover both environment changes and real in-place key-file rotation. The protected database save/fencing cases pass after removing the cache. A local measurement of 1,000 file-backed revision calculations took 48 ms. - `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and Storybook catalog checks pass. - The full [PR CI run](https://github.com/paperclipai/paperclip/actions/runs/40725425470) passes on `34ed97bbf9`: all 48 jobs, including all eight browser shards. Greptile is 5/5 on this commit, with no unresolved review threads. This final commit refreshes CI after a runner-routing fallback; its source tree matches `fb4cf488cf`. - Paperclip Review and Superagent Security Scan ended neutral because their external agents did not complete a valid assessment/receipt. Superagent passed on `fb4cf488cf`, which has the identical source tree. One request to retry the final scan returned HTTP 404. Neither check reports a code finding. - The local full-suite attempt was blocked by host System V semaphore exhaustion. Acceptance and lifecycle verification used an isolated PostgreSQL 18 container. Earlier CI runners were interrupted. A local combined run hit hook/test timeouts; the full 88-test route file passed after the builds finished. The final full CI run passes. Earlier failure logs and browser traces remain retained outside the repository. To run the browser regression: ```sh pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/agent-configuration.spec.ts ``` ## Risks - Multi-resource saves are sequential. Earlier operations can remain saved if a later operation fails. The editor reports the completed steps and requires reload. - Core settings and managed instruction files have atomic revision/hash checks. Other unversioned resource writes and external supporting files retain a race window after preflight. - Shared profile definitions affect other bindings. The editor requires `updateSharedDefinition: true` for those edits. - The editor follows native service normalization and experimental settings. Canonical reload can add generated access profiles or mask environment values. - New editor dependencies require the repository's normal automated lockfile update. This pull request does not edit the lockfile or add a database migration. ## Model Used OpenAI Codex, GPT-6 family. The exact current model version was not exposed. The work used reasoning, repository tools, code execution, and browser automation. Earlier work in this chat used the configured Codex model before a model switch; its exact model ID and context window were not exposed. The current context window size is also not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4808a3a2b1
commit
b14e1ecaf8
38 files changed
+2325
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<ALIAS>"]`, `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`.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<z.ZodObject<typeof agentConfigurationResourceShape>>;
|
||||
@@ -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<typeof agentConfigurationSpecSchema>;
|
||||
export type AgentConfigurationDocument = z.infer<typeof agentConfigurationDocumentSchema>;
|
||||
export type AgentConfigurationSource = Omit<Pick<Agent, "id" | keyof AgentConfigurationSpec>, "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));
|
||||
});
|
||||
}
|
||||
@@ -2851,3 +2851,4 @@ export * from "./prompt-injection-prevention.js";
|
||||
|
||||
export * from "./workspace-provider.js";
|
||||
export * from "./workflows.js";
|
||||
export * from "./agent-configuration.js";
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> }) {
|
||||
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<string, unknown> : {};
|
||||
const redacted = displayed && typeof displayed === "object" ? displayed as Record<string, unknown> : {};
|
||||
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;
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export interface ClaudeLoginContext {
|
||||
}
|
||||
|
||||
export interface UpdateAgentOptions {
|
||||
expectedConfigurationRevision?: string;
|
||||
expectedConfiguration?: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "defaultEnvironmentId">;
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let companyId: string;
|
||||
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
|
||||
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,
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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<ReturnType<typeof svc.getById>>) {
|
||||
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<string, unknown>) };
|
||||
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) => {
|
||||
|
||||
@@ -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<ReturnType<typeof startReviewProvider>>; 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<AgentConfigurationDocument> {
|
||||
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();
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -154,7 +154,7 @@ export const agentsApi = {
|
||||
}
|
||||
},
|
||||
getConfiguration: (id: string, companyId?: string) =>
|
||||
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
|
||||
api.get<import("@paperclipai/shared").AgentConfigurationResponse>(agentPath(id, companyId, "/configuration")),
|
||||
listConfigRevisions: (id: string, companyId?: string) =>
|
||||
api.get<AgentConfigRevision[]>(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<Agent>(agentPath(id, companyId), { runtimeSettings }),
|
||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId), data),
|
||||
api.patch<Agent & import("@paperclipai/shared").AgentConfigurationResponse>(agentPath(id, companyId), data),
|
||||
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
||||
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
||||
instructionsBundle: (id: string, companyId?: string) =>
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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<AgentLocalDetailView, typeof Sparkles>;
|
||||
|
||||
const auditItems = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
for (const key of Object.keys(before) as Array<keyof typeof before>) {
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
|
||||
export async function mapBounded<T, R>(items: T[], read: (item: T) => Promise<R>): Promise<R[]> {
|
||||
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<ToolPolicy, "selectors">): 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<string, AgentInstructionsFileDetail>(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<ReturnType<typeof loadAgentConfiguration>>;
|
||||
@@ -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 = <T>(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<typeof configuration>).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" });
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => config.env && typeof config.env === "object" && !Array.isArray(config.env) ? config.env as Record<string, unknown> : {};
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> ?? {}),
|
||||
...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); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface ConfigurationStep { label: string; apply: () => Promise<unknown> }
|
||||
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<T>(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.`);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = { ...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!));
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("../lib/agent-configuration/load")>(), loadAgentConfiguration: api.load,
|
||||
}));
|
||||
vi.mock("../lib/agent-configuration/save", async importOriginal => ({
|
||||
...await importOriginal<typeof import("../lib/agent-configuration/save")>(), 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(<QueryClientProvider client={client}>
|
||||
<AgentAdvancedTab agent={agent} onDirtyChange={dirty} onSaved={onSaved} />
|
||||
</QueryClientProvider>));
|
||||
}
|
||||
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<typeof snapshot>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<AgentConfigurationSnapshot | null>(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 <div role="alert" className="space-y-3 text-sm">
|
||||
<p>{query.error.message}</p>
|
||||
<Button variant="outline" onClick={() => query.refetch()}>Retry</Button>
|
||||
</div>;
|
||||
return <p role="status" className="text-sm text-muted-foreground">Loading configuration…</p>;
|
||||
}
|
||||
return <ConfigurationEditor key={props.agent.id} {...props} initial={initial} />;
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [reloadRequired, setReloadRequired] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [receipts, setReceipts] = useState<ConfigurationReceipt[]>([]);
|
||||
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 <div className="space-y-4">
|
||||
{receipts.length > 0 && <div className="space-y-3 rounded-md border border-border p-4" aria-label="Created credentials">
|
||||
{receipts.map((receipt, index) => <div key={`${receipt.id}-${index}`} className="space-y-2 text-sm">
|
||||
<p className="font-medium">{receipt.label}</p>
|
||||
{receipt.token ? <>
|
||||
<p className="text-muted-foreground">Copy this key now. It won’t be shown again after leaving this page.</p>
|
||||
<input readOnly aria-label={`${receipt.label} token`} value={receipt.token} className="w-full rounded-md border border-border bg-muted p-2 font-mono text-sm" />
|
||||
</> : <p className="font-mono text-muted-foreground">{receipt.id}</p>}
|
||||
</div>)}
|
||||
</div>}
|
||||
<AgentYamlEditor value={source} onChange={value => { setSource(value); if (!reloadRequired) setError(null); }} readOnly={busy} />
|
||||
{dirty && validation.changes.length > 0 && <details className="rounded-md border border-border p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">{validation.changes.length} {validation.changes.length === 1 ? "change" : "changes"} to apply</summary>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1">{validation.changes.map((label, index) => <li key={index}>{label}</li>)}</ul>
|
||||
<p className="mt-2 text-muted-foreground">Changes save in this order. If one fails, earlier changes remain saved.</p>
|
||||
</details>}
|
||||
{(validation.error || error) && <p role="alert" className="whitespace-pre-wrap text-sm text-destructive">{validation.error || error}</p>}
|
||||
<footer className="sticky bottom-0 z-10 flex items-center justify-between gap-3 border-t border-border bg-background py-4">
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { if (!operationPending.current) { operationPending.current = true; reload.mutate(); } }}>Reload saved</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span role="status" className="text-xs text-muted-foreground">{save.isPending ? `Saving: ${progress || "configuration"}…` : reload.isPending ? "Loading configuration…" : dirty ? validation.document && !validation.changes.length ? "No configuration changes" : "Unsaved changes" : ""}</span>
|
||||
<Button disabled={!dirty || busy || reloadRequired || !validation.document || !validation.changes.length} onClick={() => { if (!operationPending.current) { operationPending.current = true; save.mutate(); } }}>{save.isPending ? "Saving…" : "Save changes"}</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AgentYamlEditor({ value, onChange, readOnly }: { value: string; onChange: (value: string) => void; readOnly: boolean }) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const editor = useRef<EditorView | null>(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 <div ref={container} className="agent-yaml-editor overflow-hidden rounded-md border border-border" />;
|
||||
}
|
||||
@@ -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<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
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<AgentDetailRecord>({
|
||||
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() {
|
||||
<AgentRevisionsTab agent={agent} companyId={resolvedCompanyId ?? undefined} />
|
||||
)}
|
||||
|
||||
{activeView === "advanced" && (
|
||||
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading configuration…</p>}>
|
||||
<AgentAdvancedTab key={agent.id} agent={agent} onDirtyChange={setConfigDirty} onSavingChange={setConfigSaving}
|
||||
onSaved={updated => {
|
||||
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 });
|
||||
}} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{activeView === "run-detail" && (
|
||||
<RunsTab
|
||||
runs={heartbeats ?? []}
|
||||
|
||||
@@ -20,10 +20,13 @@ describe("agent detail navigation", () => {
|
||||
"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");
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AgentDetailView =
|
||||
| "permissions"
|
||||
| "api-keys"
|
||||
| "revisions"
|
||||
| "advanced"
|
||||
| "run-detail";
|
||||
|
||||
export type AgentLocalDetailView = Exclude<AgentDetailView, "run-detail">;
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AgentLocalDetailView, string> = {
|
||||
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" && (
|
||||
<AgentRevisionsTab agent={agent} companyId={COMPANY} />
|
||||
)}
|
||||
{view === "advanced" && (
|
||||
<AgentAdvancedTab key={agent.id} agent={agent} onDirtyChange={onDirty} onSavingChange={setSaving} onSaved={() => {}} />
|
||||
)}
|
||||
</div>
|
||||
{(saveError || permissions.error) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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,
|
||||
|
||||
@@ -37,6 +37,7 @@ const meta = {
|
||||
"permissions",
|
||||
"api-keys",
|
||||
"revisions",
|
||||
"advanced",
|
||||
],
|
||||
},
|
||||
adapterType: {
|
||||
@@ -59,6 +60,13 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user