mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Merge master agent configuration editor and cancellation recovery
Preserve both the workspace preference and agent configuration exports. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
618c5a2a3f
43 files changed
+2596
-48
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`.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Cancellation deadlock retry acceptance
|
||||
|
||||
This existing correctness slice (#17895) retries only the operator Stop database
|
||||
transaction after PostgreSQL reports `40P01`, at most three times. Provider Stop,
|
||||
workspace settlement, and activity publication remain after commit. This is not
|
||||
a new polling or scheduling duty.
|
||||
|
||||
## Current parent and outstanding review
|
||||
|
||||
The previous published head is
|
||||
`fffc8d0c46fbbeb3da4a23a36e638fefd3b8fe3d`, originally based on native progress
|
||||
`08d07ead105dc89c2af9b62f4d90bff8cc93540c`. The slice is prepared by a normal
|
||||
merge of verified native progress `6f365285f5b233a89acc714eadfead5ab145100e`.
|
||||
All four original changed-file hunks retain their exact content and context.
|
||||
Only the run-control blob index hashes differ because the parent also changed
|
||||
that file. The automatic composition is clean and preserves those parent edits.
|
||||
|
||||
The prior Ponytail review said Ship with an actionable coverage request: existing
|
||||
deadlock tests did not reach a correlated native failed run's claimed state before
|
||||
rollback. The follow-up adds two real PostgreSQL cases in the existing run-control
|
||||
suite, with no new production behavior:
|
||||
|
||||
1. Keep retry authority: claim the correlated Stop, fence the failed native run,
|
||||
cancel its real board terminal handoff, and persist cancellation activity inside
|
||||
the first transaction. Raise PostgreSQL `40P01` before commit. Retry succeeds;
|
||||
exactly one cancellation activity is durable and exactly one live notice is
|
||||
published. The correlation belongs to the committed attempt.
|
||||
2. Revoke retry authority: after that aborted transaction rolls back, an independent
|
||||
coordinator update retires retry eligibility. The second attempt returns 409,
|
||||
preserves the failed run and uncancelled handoff, publishes no cancellation
|
||||
activity, and performs no provider or workspace cleanup.
|
||||
|
||||
The existing actual two-connection lock-cycle regression and the bounded retry/
|
||||
non-deadlock cases remain in the complete suite. The new tests exercise claims
|
||||
and activity produced before a real PostgreSQL transaction abort; they do not
|
||||
infer correctness from a mocked retry callback alone.
|
||||
|
||||
## Evidence and failure accounting
|
||||
|
||||
- Both new cases pass against the retained production reset logic.
|
||||
- Removing only `publications.length = 0` makes the preserved-authority case fail:
|
||||
two live cancellation notices appear for one committed activity. Production was
|
||||
restored byte-for-byte to its integrated index before further validation.
|
||||
`/tmp/sleep-aux-native-retry-mutation-proof.json` records that comparison.
|
||||
- The negative-control harness initially expected an untruncated assertion line;
|
||||
Vitest abbreviates that line. Its actual named failure and 1-versus-2 result were
|
||||
inspected, and the matcher was corrected without rerunning or altering production.
|
||||
- Typechecking caught an optional live-event payload in the new assertion. The
|
||||
assertion now uses optional access. Preserve the original failed typecheck at
|
||||
`/tmp/sleep-aux-current-parent-types.log`; the final typecheck passes at
|
||||
`/tmp/sleep-aux-current-parent-types-final.log`.
|
||||
- **228 distinct complete cases pass:** all 48 run-control cases, including the two
|
||||
additions, and all 180 native-session runtime cases, including the existing
|
||||
controlled timeout/late-append fixture. The two targeted probes are included in
|
||||
48 and are not counted again.
|
||||
- Server typechecking, build, and module-boundary checks pass. `server/tsconfig.json`
|
||||
excludes `src/__tests__`, but includes service-local test files such as
|
||||
`run-control.test.ts`; typechecking does not replace test execution. Earlier
|
||||
statements about tests omitted by production typechecking must be read with
|
||||
that path boundary.
|
||||
|
||||
The three PostgreSQL probes use separate new disposable databases on the owned
|
||||
local cluster. Full-run results and exit receipts are in
|
||||
`/tmp/sleep-aux-current-parent-{types-final,build,boundaries,control,runner}.log`.
|
||||
The targeted positive and mutation logs are
|
||||
`/tmp/sleep-aux-native-retry-regression.log` and
|
||||
`/tmp/sleep-aux-native-retry-mutant-red.log`.
|
||||
|
||||
## Remaining gates and limits
|
||||
|
||||
This is local preparation. The dependency must merge, the normal merge onto its
|
||||
actual landed master must preserve this tested patch and all master changes, and
|
||||
fresh exact-head CI plus Greptile 5/5 must pass before protected merge. Prior
|
||||
review scores and checks on older heads are not current passes. The previous
|
||||
Snyk status was pending and is not called successful here. No shared OS resource
|
||||
cleanup, schema change, whole-repository local matrix, live provider execution,
|
||||
external alarm, physical suspension, or savings qualification is claimed.
|
||||
@@ -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.
|
||||
|
||||
@@ -3499,10 +3499,6 @@ describe("executeNativeSession recovery", () => {
|
||||
});
|
||||
|
||||
it("stops and closes a timed-out consumer even when the caller requested a warm session", async () => {
|
||||
let markAppendStarted = () => {};
|
||||
const appendStarted = new Promise<void>((resolve) => {
|
||||
markAppendStarted = resolve;
|
||||
});
|
||||
let releaseAppend = () => {};
|
||||
const appendReleased = new Promise<void>((resolve) => {
|
||||
releaseAppend = resolve;
|
||||
@@ -3515,7 +3511,6 @@ describe("executeNativeSession recovery", () => {
|
||||
let appendCommitted = false;
|
||||
const appendEvent = vi.fn(
|
||||
async (_event: PrpEvent, options?: { signal: AbortSignal }) => {
|
||||
markAppendStarted();
|
||||
await Promise.race([
|
||||
appendReleased,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
@@ -3612,28 +3607,39 @@ describe("executeNativeSession recovery", () => {
|
||||
async completeRun() {},
|
||||
};
|
||||
|
||||
const execution = executeNativeSession({
|
||||
input,
|
||||
backend,
|
||||
controlPlane: port,
|
||||
runnerInstanceId: "runner-recovery",
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
timeoutMs: 1,
|
||||
keepSessionOpen: true,
|
||||
});
|
||||
const rejection = expect(execution).rejects.toThrow(
|
||||
"native session timed out",
|
||||
);
|
||||
await appendStarted;
|
||||
await vi.waitFor(() => expect(iteratorTeardown).toHaveBeenCalledOnce());
|
||||
await rejection;
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(appendEvent).toHaveBeenCalledOnce();
|
||||
expect(appendCommitted).toBe(false);
|
||||
releaseAppend();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(appendCommitted).toBe(false);
|
||||
// Reach the pending append before expiring the consumer. A real 1 ms
|
||||
// deadline can expire during setup before the consumer reaches this case.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const execution = executeNativeSession({
|
||||
input,
|
||||
backend,
|
||||
controlPlane: port,
|
||||
runnerInstanceId: "runner-recovery",
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
timeoutMs: 1,
|
||||
keepSessionOpen: true,
|
||||
});
|
||||
const rejection = expect(execution).rejects.toThrow(
|
||||
"native session timed out",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(appendEvent).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await rejection;
|
||||
expect(iteratorTeardown).toHaveBeenCalledOnce();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(appendEvent).toHaveBeenCalledOnce();
|
||||
expect(appendCommitted).toBe(false);
|
||||
releaseAppend();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(appendCommitted).toBe(false);
|
||||
} finally {
|
||||
releaseAppend();
|
||||
releaseTeardown();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes a failed session while retaining an uncancellable event read", async () => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -2861,5 +2861,5 @@ export * from "./prompt-injection-prevention.js";
|
||||
|
||||
export * from "./workspace-provider.js";
|
||||
export * from "./workflows.js";
|
||||
|
||||
export { updateWorkspacePreferenceSchema, type UpdateWorkspacePreference, type WorkspacePreference } from "./workspace-preferences.js";
|
||||
export * from "./agent-configuration.js";
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns } from "@paperclipai/db";
|
||||
import { agents, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns } from "@paperclipai/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
|
||||
import { runningProcesses } from "../../../adapters/index.js";
|
||||
import { terminateLocalService } from "../../local-service-supervisor.js";
|
||||
import { createHeartbeatRunState } from "../run-state.js";
|
||||
@@ -65,15 +66,17 @@ export function callbacks(db: Db): TestCallbacks {
|
||||
failedProcessRunCancellations: new Map<string, { settled: Promise<void>; failed: boolean; error?: unknown }>(),
|
||||
} satisfies HeartbeatRunControlDependencies;
|
||||
}
|
||||
export const runControlPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_RUN_CONTROL_TEST_DATABASE_URL");
|
||||
export const runControlPostgresSupport = externalTestDatabaseUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
||||
if (!runControlPostgresSupport.supported) console.warn(`Skipping run-control database tests: ${runControlPostgresSupport.reason}`);
|
||||
|
||||
export function setupRunControlDatabase(name: string, ready: (db: Db) => void) {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase(name);
|
||||
db = createDb(database.connectionString);
|
||||
if (externalTestDatabaseUrl) await applyPendingMigrations(externalTestDatabaseUrl);
|
||||
else database = await startEmbeddedPostgresTestDatabase(name);
|
||||
db = createDb(externalTestDatabaseUrl ?? database!.connectionString);
|
||||
ready(db);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runningProcesses } from "../../adapters/index.js";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
import { captureException } from "../../sentry.js";
|
||||
import { terminateLocalService } from "../local-service-supervisor.js";
|
||||
import { publishLiveEvent } from "../live-events.js";
|
||||
import { createHeartbeatRunState } from "./run-state.js";
|
||||
import { createHeartbeatRunControl } from "./run-control.js";
|
||||
import { preserveCancelledNativeWorkspaces } from "../native-runtime/native-failed-workspace-recovery.js";
|
||||
@@ -173,6 +174,79 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
|
||||
)).toMatchObject({ status: "cancelled" });
|
||||
});
|
||||
|
||||
it.each([["40P01", 3], ["XX000", 1]] as const)("bounds cancellation transaction attempts for PostgreSQL %s", async (code, attempts) => {
|
||||
const { company, agent } = await fixture();
|
||||
const run = await liveRun(company.id, agent.id);
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Cancellation failure", status: "in_progress",
|
||||
assigneeAgentId: agent.id, executionRunId: run.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ issueId: issue.id, contextSnapshot: { issueId: issue.id } }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.execute(sql`create sequence run_control_cancel_attempts`);
|
||||
await db.execute(sql.raw(`create function run_control_cancel_fault() returns trigger language plpgsql as $$
|
||||
begin
|
||||
if new.result_json->'executionCancellation'->>'state' = 'requested' then
|
||||
perform nextval('run_control_cancel_attempts');
|
||||
raise exception using errcode = '${code}', message = 'Synthetic cancellation transaction failure';
|
||||
end if;
|
||||
return new;
|
||||
end $$`));
|
||||
await db.execute(sql`create trigger run_control_cancel_fault before update on heartbeat_runs
|
||||
for each row execute function run_control_cancel_fault()`);
|
||||
const deps = callbacks(db);
|
||||
try {
|
||||
await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Operator Stop", {
|
||||
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
|
||||
})).rejects.toMatchObject({ cause: { code } });
|
||||
const [counter] = await db.execute<{ count: number }>(sql`select last_value::int as count from run_control_cancel_attempts`);
|
||||
expect(counter.count).toBe(attempts);
|
||||
expect(await deps.getRun(run.id)).toMatchObject({ status: "running", resultJson: null });
|
||||
expect(terminateLocalService).not.toHaveBeenCalled();
|
||||
expect(deps.appendRunEvent).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await db.execute(sql`drop trigger run_control_cancel_fault on heartbeat_runs`);
|
||||
await db.execute(sql`drop function run_control_cancel_fault()`);
|
||||
await db.execute(sql`drop sequence run_control_cancel_attempts`);
|
||||
}
|
||||
});
|
||||
|
||||
it("finishes operator Stop after a concurrent run writer causes a transaction deadlock", async () => {
|
||||
const { company, agent } = await fixture();
|
||||
const run = await liveRun(company.id, agent.id);
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Stop contention", status: "in_progress",
|
||||
assigneeAgentId: agent.id, executionRunId: run.id, checkoutRunId: run.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ issueId: issue.id, contextSnapshot: { issueId: issue.id } }).where(eq(heartbeatRuns.id, run.id));
|
||||
let enter!: () => void, release!: () => void;
|
||||
const entered = new Promise<void>(resolve => { enter = resolve; });
|
||||
const released = new Promise<void>(resolve => { release = resolve; });
|
||||
let blockerPid = 0;
|
||||
const writer = db.transaction(async tx => {
|
||||
// Make Stop's default detector see the cycle first, so this reproduces
|
||||
// the API-side victim observed in CI rather than a random victim.
|
||||
await tx.execute(sql`set local deadlock_timeout = '10s'`);
|
||||
const [backend] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||
blockerPid = backend.pid;
|
||||
await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)).for("update");
|
||||
enter(); await released;
|
||||
await tx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, issue.id));
|
||||
});
|
||||
await entered;
|
||||
const deps = callbacks(db);
|
||||
const stopping = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Operator Stop", {
|
||||
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
|
||||
}).then(value => ({ value, error: undefined }), error => ({ value: undefined, error }));
|
||||
try {
|
||||
await vi.waitFor(async () => {
|
||||
const waiting = await db.execute(sql`select pid from pg_stat_activity
|
||||
where ${blockerPid} = any(pg_blocking_pids(pid)) and wait_event_type = 'Lock'`);
|
||||
expect(waiting.length).toBeGreaterThan(0);
|
||||
}, { timeout: 5000 });
|
||||
} finally { release(); }
|
||||
await writer;
|
||||
const result = await stopping;
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({ status: "cancelled" });
|
||||
expect(await deps.getRun(run.id)).toMatchObject({ status: "cancelled" });
|
||||
});
|
||||
|
||||
it.each(["queued", "running"] as const)("preserves case intent when a guarded Stop loses to %s promotion", async status => {
|
||||
const { company, agent } = await fixture();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Promoted case work", status: "todo", assigneeAgentId: agent.id }).returning();
|
||||
@@ -393,6 +467,63 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
|
||||
.toMatchObject({ status: "in_progress", phase: "verification", completedAt: null });
|
||||
});
|
||||
|
||||
it.each([false, true])("discards rolled-back native Stop claims and activity before retry (authority revoked=%s)", async revokeAuthority => {
|
||||
const f = await boardTerminalFixture("running", "native");
|
||||
const requestId = randomUUID(), deps = callbacks(db);
|
||||
vi.mocked(publishLiveEvent).mockClear();
|
||||
const transact = db.transaction.bind(db);
|
||||
let injected = false;
|
||||
const transaction = vi.spyOn(db, "transaction").mockImplementationOnce(async (operation, config) => {
|
||||
try {
|
||||
return await transact(async tx => {
|
||||
const result = await operation(tx);
|
||||
const [claimed] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(claimed.resultJson?.startupCancellation).toMatchObject({ cancellationRequestId: requestId });
|
||||
expect(claimed.resultJson?.executionCancellation).toMatchObject({ state: "requested" });
|
||||
expect((await tx.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0].state).toBe("cancelled");
|
||||
expect((await tx.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)))
|
||||
.filter(row => row.action === "case.handoff_cancelled")).toHaveLength(1);
|
||||
expect(deps.getAgent).not.toHaveBeenCalled();
|
||||
injected = true;
|
||||
await tx.execute(sql`do $$ begin raise exception using errcode = '40P01', message = 'Deadlock after native Stop claim'; end $$`);
|
||||
return result;
|
||||
}, config);
|
||||
} catch (error) {
|
||||
if (injected && revokeAuthority) await db.update(nativeRunFinalizations)
|
||||
.set({ phase: "terminal_failure", failureCode: "retry_authority_revoked", nextAttemptAt: null })
|
||||
.where(eq(nativeRunFinalizations.runId, f.run.id));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
try {
|
||||
const stopping = createHeartbeatRunControl(db, deps).cancelRunInternal(f.run.id, "Retry native Stop", {
|
||||
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
|
||||
cancellationRequestId: requestId, cancellationRequestedByUserId: "operator",
|
||||
});
|
||||
if (revokeAuthority) {
|
||||
await expect(stopping).rejects.toMatchObject({ status: 409 });
|
||||
expect(await deps.getRun(f.run.id)).toMatchObject({ status: "failed", resultJson: null });
|
||||
expect(deps.getAgent).not.toHaveBeenCalled();
|
||||
expect(deps.options.closeWarmNativeSessionsForRun).not.toHaveBeenCalled();
|
||||
expect(deps.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await expect(stopping).resolves.toMatchObject({ status: "cancelled" });
|
||||
expect((await deps.getRun(f.run.id))?.resultJson?.startupCancellation).toMatchObject({ cancellationRequestId: requestId });
|
||||
}
|
||||
expect(injected).toBe(true);
|
||||
expect(transaction.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0].state)
|
||||
.toBe(revokeAuthority ? "awaiting_completion" : "cancelled");
|
||||
const expected = revokeAuthority ? 0 : 1;
|
||||
expect((await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)))
|
||||
.filter(row => row.action === "case.handoff_cancelled")).toHaveLength(expected);
|
||||
expect(vi.mocked(publishLiveEvent).mock.calls.map(([event]) => event)
|
||||
.filter(event => event.companyId === f.company.id && event.type === "activity.logged"
|
||||
&& event.payload?.action === "case.handoff_cancelled")).toHaveLength(expected);
|
||||
expect(terminateLocalService).not.toHaveBeenCalled();
|
||||
} finally { transaction.mockRestore(); }
|
||||
});
|
||||
|
||||
it("stops current preparing execution despite a prior terminal checkout when cancelling board intent", async () => {
|
||||
// Queue admission binds the new execution before preparation adopts its
|
||||
// predecessor's checkout. The execution owner is authoritative in this gap.
|
||||
|
||||
@@ -1134,7 +1134,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
||||
let caseHandoffRetryClaimed = false;
|
||||
if (handoffIssueId && supersedesCaseHandoff && options.resultJson?.caseHandoffYieldStop !== true) {
|
||||
const publications: ActivityPublication[] = [];
|
||||
const supersessionApplied = await db.transaction(async tx => {
|
||||
const applySupersession = () => db.transaction(async tx => {
|
||||
const [issue] = await tx.select({ id: issues.id, executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId,
|
||||
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
|
||||
.where(and(eq(issues.companyId, run!.companyId), eq(issues.id, handoffIssueId))).for("update");
|
||||
@@ -1189,7 +1189,27 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
||||
await cancelCaseHandoffsForIssue(tx as unknown as Db, { companyId: run!.companyId, issueId: handoffIssueId,
|
||||
sourceRunId: runId, includeUnboundTerminalIntent: Boolean(fenced && ownsTask), reason, publications });
|
||||
return true;
|
||||
}).catch(rethrowNativeCancellationLockConflict);
|
||||
});
|
||||
let supersessionApplied = false;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const previousRun = run;
|
||||
try {
|
||||
supersessionApplied = await applySupersession();
|
||||
break;
|
||||
} catch (error) {
|
||||
const value = parseObject(error);
|
||||
const deadlock = value.code === "40P01" || parseObject(value.cause).code === "40P01";
|
||||
if (!deadlock || attempt >= 2) rethrowNativeCancellationLockConflict(error);
|
||||
// PostgreSQL aborted the entire transaction. Discard its local
|
||||
// receipts before re-reading current authority under fresh locks.
|
||||
// Provider Stop and publication happen only after this boundary.
|
||||
run = previousRun;
|
||||
cancellationClaimed = false;
|
||||
caseHandoffRetryClaimed = false;
|
||||
publications.length = 0;
|
||||
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
publications.forEach(publishActivity);
|
||||
if (!supersessionApplied) return null;
|
||||
}
|
||||
|
||||
@@ -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