mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
feat(connections): deliver saved instructions to authorized agent turns (#15216)
Persist optional connection instructions and deliver authorized snapshots to agent execution prompts. Keep provider templates with each app definition, preserve edits and opt-outs, and replace sessions when guidance or access changes. Use shared production settings across setup and Permissions, with source visibility in agent Instructions. Add the initial memory-provider defaults and managed Honcho workspace configuration. Include migration 0298 and regression coverage for generic providers, runtime delivery, authorization, and catalog regeneration. Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
1 parent
2c43b39167
commit
4857799a88
54 files changed
+2400
-66
No files matched your search
@@ -1906,3 +1906,15 @@ unavailable. Preserve current ownership and newer-work fences. See
|
||||
endpoints delegate to sources while retaining response shapes.
|
||||
- GitHub.com, manual refresh only. No upstream editing, polling, webhook sync, commits,
|
||||
or pull-request creation in this milestone.
|
||||
|
||||
### Connection instructions
|
||||
|
||||
Connections can store optional, versioned agent instructions independently of
|
||||
provider and transport. Catalog templates control editor visibility; saved
|
||||
settings and runtime delivery also support connections without a template.
|
||||
The server includes instructions only when the connection and at least one
|
||||
action are available to the run's agent and responsible identity. An immutable
|
||||
per-turn snapshot participates in session compatibility, so subsequent turns
|
||||
remove stale instructions after edits or access revocation. See
|
||||
[Connection instructions](connections/CONNECTION-INSTRUCTIONS.md) for contracts,
|
||||
UI conventions, custom adapter integration, and initial memory templates.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Connection instructions
|
||||
|
||||
Connection instructions are an optional, provider-independent capability. A saved
|
||||
connection can contribute a short paragraph to an authorized agent’s execution
|
||||
prompt. Memory providers are the first catalog templates; custom and non-memory
|
||||
connections use exactly the same settings, authorization, and delivery path.
|
||||
|
||||
## Catalog and saved settings
|
||||
|
||||
A connector can declare `agentInstructions: { id, version, text }` in its
|
||||
`AppDefinition`. Author reviewed defaults directly in that app's
|
||||
`packages/shared/src/app-definitions/<slug>.json`, alongside its configuration
|
||||
and supporting provider `docsUrl`. This field is the source of truth; there is
|
||||
no separate template registry. Catalog ingestion validates and preserves it,
|
||||
including edits or removal, when regenerating the other definition fields. Run
|
||||
`node scripts/ingest-app-definitions.mjs --definitions-only` to verify. Template
|
||||
text is limited to 2,000 characters. Remote MCP initialization prose is never
|
||||
adopted as trusted instructions automatically.
|
||||
|
||||
Every `ToolConnection` has nullable `agentInstructions`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"text": "Use the release handbook before changing a deployment. Cite the checklist.",
|
||||
"template": { "id": "handbook.usage", "version": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
The template provenance is optional. Authorized custom instructions can be saved
|
||||
without a catalog template. `null` means no instructions. Disabling retains text
|
||||
and tool access. New catalog connections persist the current default enabled;
|
||||
the initial migration backfills existing connections for the five participating
|
||||
providers. A reconnect, OAuth return, or catalog refresh retains saved settings.
|
||||
Catalog version changes do not replace custom text or opt-outs. **Reset to
|
||||
default** explicitly adopts the current template and preserves the toggle.
|
||||
|
||||
Existing connect, finish, and connection PATCH requests accept the same nullable
|
||||
setting. Mutations use connection configuration permissions and company checks,
|
||||
and write activity records. Required provider configuration lives in
|
||||
`config.methodConfig`, independently of instruction text and enabled state.
|
||||
|
||||
## UI convention
|
||||
|
||||
The editor appears only when explicit template metadata exists. Setup (including
|
||||
inline setup and OAuth) uses the current flow, without another wizard step. The
|
||||
Permissions page places **Agent instructions** after agent access and before
|
||||
Actions. It shows **Tell agents to use {provider}**, visible text, editing,
|
||||
**Reset to default** after changes, and an amber notice when off. Failed saves
|
||||
retain the draft. Agent Instructions includes a read-only **From connections**
|
||||
section with source links and an explanation of task-dependent availability.
|
||||
Disabled instructions remain inspectable there with an amber notice.
|
||||
|
||||
## Runtime delivery and compatibility
|
||||
|
||||
The gateway resolves instructions using the stored run’s company, agent, task,
|
||||
project, active responsible identity, connection grants, and effective action
|
||||
policy. At least one tool must be available (including tools requiring approval).
|
||||
The resolver uses persisted settings and the cached catalog; it does not contact
|
||||
providers. It does not gate delivery on a provider name, memory category, or the
|
||||
presence of a catalog template.
|
||||
Prompt assembly checks access to candidate connections’ cached tools directly,
|
||||
using the same task restrictions and policy decisions as discovery. It does not
|
||||
enter the discovery queue, so queue saturation neither drops guidance nor replaces
|
||||
an otherwise compatible session. Revocation is still checked on every turn.
|
||||
|
||||
Eligible blocks are sorted by connection ID. Each block identifies its
|
||||
connection, selected grant, optional template provenance, and declared public
|
||||
configuration. Secret/password fields are excluded. Missing required public
|
||||
configuration withholds standing instructions. The snapshot is `{ text, digest }`,
|
||||
where `digest` is SHA-256 of the complete UTF-8 text. Declared public configuration and
|
||||
source changes therefore change the digest as well as text edits.
|
||||
|
||||
The server replaces caller-supplied runtime instruction fields before executing.
|
||||
Native Runner receives the snapshot in `runtimeContext.connectionInstructions`
|
||||
and composes it alongside agent instructions. Legacy adapters receive it through
|
||||
shared wake-prompt composition on fresh and resumed turns. Session compatibility
|
||||
includes the snapshot: changing or removing it replaces an incompatible session,
|
||||
including when live tool refresh would otherwise retain that session. Active
|
||||
turns retain the snapshot with which they started. Older native snapshots without
|
||||
this field remain valid. No AGENTS.md or shared harness home is modified.
|
||||
|
||||
Custom integration contracts:
|
||||
|
||||
- **HTTP adapter:** the JSON request contains top-level `connectionInstructions`
|
||||
(`{ text, digest }` or `null`), also available on invocation `context`.
|
||||
This server-owned field overrides `payloadTemplate`. Append its text alongside
|
||||
agent instructions before invoking a model; do not reuse a session with a
|
||||
different digest.
|
||||
- **Process adapter:** `PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE` points to a private
|
||||
JSON file containing the same snapshot. Read it during the invocation and
|
||||
append `.text` to your model prompt. It is empty when no instructions apply.
|
||||
Configured or inherited paths cannot override it. The temporary file is removed
|
||||
when the process exits, including failure paths.
|
||||
|
||||
Instruction guidance grants no new permissions. Approval requirements remain
|
||||
attached to tool calls, and remembered content does not override the current task.
|
||||
Provider memory scope follows the provider’s real configuration; Paperclip does
|
||||
not claim isolation from invented agent IDs or tags.
|
||||
|
||||
## Honcho configuration
|
||||
|
||||
Honcho requires `config.methodConfig.workspaceId` for new setup. The field is
|
||||
expanded above instructions, with validation beside it. For tools declaring a
|
||||
`workspace_id` argument, the existing managed-argument mechanism projects the
|
||||
configured workspace after caller arguments and removes the field from the
|
||||
agent-visible schema. Pending approvals compare against the current projected
|
||||
arguments, so changing the workspace invalidates the old approved invocation.
|
||||
Existing connections missing a workspace retain their tools and manual workspace
|
||||
arguments, but receive no standing guidance until configured.
|
||||
|
||||
## Verification
|
||||
|
||||
`server/src/__tests__/connection-instructions.test.ts` persists a non-memory
|
||||
fixture, resolves effective access, captures a real process invocation, and checks
|
||||
shared/native prompt composition. Route tests cover permissions and OAuth draft
|
||||
preservation. Session tests cover edits/removal and incompatible resumes.
|
||||
Storybook uses production components backed by stateful mock APIs, including
|
||||
Notion with and without explicit templates. Model behavior evaluations and new
|
||||
agent/company memory scope controls are deferred.
|
||||
@@ -122,6 +122,7 @@ read/write proof separately and never describe metadata discovery as live proof.
|
||||
- [Current access defaults](#current-default-access-policy)
|
||||
- [Golden-path agent tutorial](#golden-path-agent-tutorial)
|
||||
- [Connection UX and user journeys](#connection-ux-and-user-journeys)
|
||||
- [Optional agent instructions](#optional-agent-instructions)
|
||||
- [Chat and email connector UX](./CHAT-CONNECTOR-UX.md)
|
||||
- [Production validation evidence](#step-9-align-with-production-validation)
|
||||
- [AppDefinition field reference](#appdefinition-field-reference)
|
||||
@@ -147,6 +148,59 @@ A complete connector proposal produces:
|
||||
denied/quarantined call when the method declares one, revoke, and audit
|
||||
evidence.
|
||||
|
||||
## Optional agent instructions
|
||||
|
||||
Some connections need standing guidance: tools tell an agent what it *can* do,
|
||||
while a short paragraph explains when it should use them. Memory is the first
|
||||
use case. See [Connection instructions](CONNECTION-INSTRUCTIONS.md) for the
|
||||
generic catalog metadata, saved settings, API, runtime, and integration contracts.
|
||||
**Design explorations → Connections → Agent instructions** in Storybook uses
|
||||
production components with mocked APIs.
|
||||
|
||||
When adding a template:
|
||||
|
||||
- Show the section only when the connector explicitly provides a nonempty
|
||||
instruction template. Use **Agent instructions** and the checkbox
|
||||
**Tell agents to use {provider}**, with the exact paragraph visible. No template
|
||||
means no instructions UI, including no blank editor.
|
||||
Keep setup in the existing flow and its single footer; add no required test or
|
||||
extra wizard step. Use the current shared access defaults/disclosure.
|
||||
- Use a reviewed, versioned provider-specific suggestion. Explain when to recall,
|
||||
when to save, the allowed context, and what to do if a call fails. Prefer one
|
||||
short paragraph over a full skill or a pasted tool catalog. Avoid unsupported
|
||||
promises about hooks, automatic transcript capture, or private memory.
|
||||
- Default provided instructions on for every participating connector. Let users
|
||||
edit, **Reset to default** after changes, or disable guidance while retaining
|
||||
tools. Show a compact amber notice when off. Reconnect and catalog refresh
|
||||
preserve explicit opt-outs and custom text. Omit suggestion attribution badges
|
||||
and routine lifecycle/storage helper copy.
|
||||
- Edit in an optional **Agent instructions** section of the existing **Permissions**
|
||||
page, between agent access and Actions. Use the actual connection page shell,
|
||||
credential form, and footers in review stories; include a baseline story for
|
||||
comparison rather than constructing parallel page chrome. Show recipients
|
||||
from current assignments and a read-only, source-linked copy on the agent.
|
||||
Keep action controls and tests in the existing **Permissions** screen.
|
||||
- Resolve runtime guidance with the same company, agent, task, responsible-user,
|
||||
and grant checks used for tools. Compose a revisioned block alongside agent
|
||||
instructions; never permanently edit AGENTS.md or shared harness home files.
|
||||
Verify fresh and resumed turns, removal, revoked access, and unsupported adapters.
|
||||
- Keep required provider settings, such as Honcho workspace, expanded near the
|
||||
top of setup and configuration, above instructions. Put validation beside the
|
||||
field and block completion while required values are missing. Memory stays an
|
||||
ordinary connection; do not add a default-memory chooser or agent preference.
|
||||
Keep provider binding identifiers separate from editable prose. Agent IDs,
|
||||
tags, and query filters alone are not access-control boundaries. Any promised
|
||||
isolation needs server enforcement across all relevant tool paths.
|
||||
- Treat MCP initialization instructions as external provider content. Review
|
||||
them as template source; do not silently inject arbitrary remote text or let
|
||||
a provider update overwrite an operator's instructions. Guidance never grants
|
||||
additional tool permissions.
|
||||
- Prove both delivery and behavior: deterministic assignment/resume checks, then
|
||||
a bounded save → **fresh session** recall with synthetic data through the real
|
||||
run gateway. Record exact model/template/catalog versions, tool receipts,
|
||||
costs, ingestion timeout, and cleanup. Discovery and a model's “saved” reply
|
||||
do not prove memory works. See the linked plan for the minimal matrix.
|
||||
|
||||
## Use This Document As The Checklist
|
||||
|
||||
An agent implementing a connection should be able to begin with only a provider
|
||||
@@ -158,7 +212,7 @@ The shortest valid implementation usually changes these files:
|
||||
|
||||
```text
|
||||
scripts/ingest-app-definitions.mjs # human-authored definition source
|
||||
packages/shared/src/app-definitions/<slug>.json # generated definition
|
||||
packages/shared/src/app-definitions/<slug>.json # definition with app-owned instruction template
|
||||
packages/shared/src/app-definitions.generated.ts # generated registry
|
||||
ui/public/brands/apps/<slug>.svg # official, sanitized mark
|
||||
ui/public/brands/apps/manifest.json # runtime branding paths
|
||||
@@ -708,7 +762,13 @@ definitions and visible manifest entries to match exactly.
|
||||
|
||||
### Phase 5: Author the definition at the durable source
|
||||
|
||||
The checked-in provider JSON files are generated. Do not edit one and stop.
|
||||
Transport/auth fields in the checked-in provider JSON files are generated. Do
|
||||
not edit those fields and stop. Optional `agentInstructions: { id, version, text }`
|
||||
templates are authored directly in each app's JSON definition, alongside its
|
||||
supporting `docsUrl`. Ingestion validates and preserves that field; edit or
|
||||
remove it there, and increment its version when changing the default. There is
|
||||
no central instruction-template registry. See
|
||||
[Connection instructions](./CONNECTION-INSTRUCTIONS.md) for the runtime contract.
|
||||
|
||||
1. Add or update the provider in `scripts/ingest-app-definitions.mjs`.
|
||||
2. Update `packages/shared/src/self-serve-mcp-research.json` when it belongs to
|
||||
|
||||
@@ -8,7 +8,9 @@ rotate credentials without re-enabling the toggle.
|
||||
Generic custom MCP connections retain their existing behavior.
|
||||
|
||||
These are ordinary company-scoped tool connections. There is no separate memory
|
||||
page, automatic conversation upload, prompt injection, or background memory sync.
|
||||
page, automatic conversation upload, or background memory sync. Reviewed defaults
|
||||
use the generic [connection instructions](CONNECTION-INSTRUCTIONS.md) capability
|
||||
to explain when to recall and save; operators can edit or disable them.
|
||||
Agents explicitly invoke the provider's tools through the existing gateway, and
|
||||
operators manage access and actions on the regular Permissions screen.
|
||||
|
||||
@@ -22,7 +24,7 @@ Official documentation and public endpoint discovery checked September 24, 2026.
|
||||
| [Zep](https://help.getzep.com/memory-mcp-server) | Remote MCP, `https://api.getzep.com/mcp`, OAuth | Configure Memory MCP and the identity provider in Zep first. A project administrator assigns MCP seats and shared graphs. Sign in with the assigned work identity. An ordinary Zep API key is not the credential for this endpoint. |
|
||||
| [Supermemory](https://supermemory.ai/docs/supermemory-mcp/mcp) | Remote MCP, `https://mcp.supermemory.ai/mcp`, OAuth | Sign in and select the workspace, read/write access, and optional container tags offered by Supermemory. Developer API keys are separate from hosted MCP sign-in. |
|
||||
| [Cognee](https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp) | Bundled Cloud API bridge, Cloud API key | Copy the tenant API Base URL and key from Cognee Cloud → API Keys. Requires an active Cloud workspace. The bundled bridge works in public deployments without a local MCP runtime host. |
|
||||
| [Honcho](https://honcho.dev/docs/v3/guides/integrations/mcp) | Remote MCP, `https://mcp.honcho.dev`, Bearer API key | Create an organization and API key in the Honcho dashboard. Workspace, peer, and session selectors remain explicit provider tool arguments. |
|
||||
| [Honcho](https://honcho.dev/docs/v3/guides/integrations/mcp) | Remote MCP, `https://mcp.honcho.dev`, Bearer API key | Create an organization and API key in the Honcho dashboard. Configure the required workspace in Paperclip; it is projected into workspace-scoped tool arguments. Peer and session selectors follow provider/task context. |
|
||||
|
||||
Zep and Supermemory use user grants, the existing PKCE OAuth broker, and automatic
|
||||
client registration/discovery. Zep advertises its authorization server at
|
||||
|
||||
@@ -1105,8 +1105,7 @@
|
||||
"https://honcho.dev/docs/v3/guides/integrations/mcp"
|
||||
],
|
||||
"reviewedAt": "2026-09-30",
|
||||
"liveProof": "not-run",
|
||||
"keyPermissions": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions."
|
||||
"liveProof": "not-run"
|
||||
},
|
||||
{
|
||||
"app": "hugging-face",
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
# Connection instructions and a small memory smoke suite
|
||||
|
||||
Implementation update (2026-10-05): the generic capability is specified in
|
||||
[Connection instructions](../connections/CONNECTION-INSTRUCTIONS.md). The
|
||||
Storybooks now use production components. Model evaluations in this original
|
||||
design plan remain deferred.
|
||||
|
||||
Status: original UX proposal. The production implementation now follows the
|
||||
linked capability contract. Live model evaluations remain deferred.
|
||||
|
||||
## Decision
|
||||
|
||||
Make **Agent instructions** an optional connection capability. A connection can
|
||||
provide a short paragraph that tells assigned agents when and how to use it.
|
||||
Memory is the first use case. Use the same pattern for other services whose
|
||||
purpose needs standing guidance, without requiring users to install a skill.
|
||||
|
||||
The checkbox says **Tell agents to use Honcho** (using the provider name). Show
|
||||
this section only when the connection explicitly provides a nonempty instruction
|
||||
template. Default it on, show the exact paragraph, and offer **Reset to default**
|
||||
only after edits. Turning it off preserves the paragraph and shows an amber notice. On the agent, show a read-only copy
|
||||
with its source and a link back to the connection.
|
||||
|
||||
Compose this into runtime instructions beside the agent's own instructions.
|
||||
Do not append persistent text to AGENTS.md: that would create duplicate, stale
|
||||
instructions after editing, reconnecting, changing assignments, or disconnecting.
|
||||
An optional generated run-local file can be a delivery mechanism for a harness,
|
||||
but the canonical agent files remain untouched.
|
||||
|
||||
## What exists, and what is still unproven
|
||||
|
||||
The five experimental catalog entries are ordinary remote MCP connections behind
|
||||
`enableMemoryConnectors`. `doc/connections/memory-tool-inventory.json` records
|
||||
authenticated discovery on **2026-09-24**, explicitly not execution of every tool.
|
||||
|
||||
| Provider | Captured tools | Smallest useful path in that snapshot |
|
||||
| --- | ---: | --- |
|
||||
| Mem0 | 11 | `add_memory` → `get_event_status` if asynchronous → `search_memories` / `get_memory` |
|
||||
| Honcho | 40 | Resolve workspace/peer/session → `add_messages_to_session` → `search` or `get_session_messages` |
|
||||
| Supermemory | 16 | `add_memory` → `search_memory`, scoped to the approved space/tag |
|
||||
| Zep | 12 | `add_memory` / `add_memory_to_graph` → `search_graph` |
|
||||
| Cognee | 3 | `remember` → `recall`; `forget` for supported cleanup |
|
||||
|
||||
These are observed names, not a cross-provider API or a claim that every
|
||||
operation works. Capture fresh schemas before writing live fixtures. In particular,
|
||||
Supermemory's `add_memory` is classified as destructive in Paperclip because
|
||||
its action parameter can delete. Honcho has a richer context model than a simple
|
||||
key/value store. Show the real catalog through the existing Permissions screen.
|
||||
|
||||
Current coverage includes catalog/auth setup, experimental gating, and risk
|
||||
classification (`memory-connectors.test.ts`, `tool-access-service.test.ts`).
|
||||
The older `evals/promptfoo/tests/phase5-memory-control-surfaces.yaml` asks models
|
||||
to explain policy. It does not prove that memory was saved or retrieved.
|
||||
The similarly named gateway-listing-memory and runner shared-memory tests concern
|
||||
resource usage, not semantic memory.
|
||||
|
||||
Honcho's [official MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp)
|
||||
says it supplies usage instructions during initialization. Paperclip's
|
||||
`server/src/services/mcp-http.ts:initializeMcpHttpSession` reads the initialize
|
||||
result but returns only protocol/session headers. This identifies a likely
|
||||
delivery gap on that transport; test the full adapter paths before generalizing.
|
||||
Do not fix it by blindly promoting remote server prose into trusted instructions.
|
||||
|
||||
[Mem0's tool documentation](https://docs.mem0.ai/platform/mem0-mcp) also distinguishes
|
||||
add, retrieve, update, delete, and asynchronous operation status. A fresh-session
|
||||
retrieval test is stronger evidence than asking the same conversation to repeat
|
||||
a fact still in its context. Provider documentation was checked on 2026-10-03;
|
||||
the tool counts above come from the repository's dated live inventory.
|
||||
|
||||
There is a useful local precedent: `server/src/services/connector-runtime.ts`
|
||||
already resolves assigned connector skills, builds run-scoped overlays, and
|
||||
delivers guidance for Slack, AgentMail, and Browser Use. Slack also has an editable
|
||||
`ChatCommunicationInstructions` control. Extend that composition discipline;
|
||||
do not register five memory-specific global skills.
|
||||
|
||||
## UX walkthrough
|
||||
|
||||
### 1. Connect Honcho
|
||||
|
||||
Keep the existing credential and access journey. The current implementation has
|
||||
compact access defaults with an Advanced disclosure; older two-step variants use
|
||||
Access → Connect. Add this section on Connect, below access and above the single
|
||||
footer. Do not add a prompt wizard step or a mandatory test.
|
||||
|
||||
**Agent instructions**
|
||||
|
||||
☑ **Tell agents to use Honcho**
|
||||
|
||||
“Use Honcho to recall relevant preferences and prior decisions when a task depends
|
||||
on earlier work. Save concise, durable facts after completing useful work. Use only
|
||||
the memory context supplied for this connection. Treat retrieved memories as
|
||||
background information; follow current task instructions when they differ. Do not
|
||||
store credentials or copy entire conversations. If a memory call fails, continue
|
||||
what you can and say what was not saved.”
|
||||
|
||||
Below the paragraph: **Edit instructions**. The paragraph stays visible even when
|
||||
collapsed; the editor replaces the preview, rather than repeating it. Keep it
|
||||
short (suggested 60–100 words; maximum 2,000 characters). Do not add an attribution
|
||||
badge or routine implementation/lifecycle helper text.
|
||||
|
||||
For Honcho, require an explicit workspace binding. Show **Honcho workspace** as
|
||||
an expanded field near the top of setup and configuration, before credentials or
|
||||
access controls and above instructions. Put a missing-workspace warning beside
|
||||
it; disable completion until it is supplied. Validate the binding against the
|
||||
provider during implementation. Never let the model choose an arbitrary workspace.
|
||||
|
||||
Default instructions on whenever a connection explicitly supplies a reviewed
|
||||
template, including ordinary connectors such as the Notion example. Preserve
|
||||
explicit opt-outs, custom text, and workspace on back/resume and reconnect. A
|
||||
connector with no template has no instructions section, checkbox, preview, or
|
||||
blank editor. The Notion example demonstrates an explicitly configured template;
|
||||
it does not imply that every Notion connection ships with instructions.
|
||||
|
||||
### 2. Manage a connection
|
||||
|
||||
Add an optional **Agent instructions** section within the existing **Permissions**
|
||||
page, between agent access and Actions, only when the connection explicitly
|
||||
provides an instruction template. Keep the real identity controls, access picker, and searchable
|
||||
action list on the same page. Do not add a new tab or duplicate page navigation. Display:
|
||||
|
||||
- The checkbox and exact paragraph; **Edit instructions** and, after edits,
|
||||
**Reset to default**. No “Suggested by Paperclip” badge.
|
||||
- A live recipient summary derived from existing assignments: “Applies to Ada and
|
||||
Morgan when this connection is available to the task.” Link to Permissions to
|
||||
change access. “All agents” explicitly includes future eligible agents.
|
||||
- Honcho workspace is a visible field near the top of the page, before access
|
||||
controls, with its missing-value warning in the same place.
|
||||
- **Cancel** left and **Save instructions** right. Save errors retain the draft.
|
||||
- A plain saved receipt: “Saved.”
|
||||
|
||||
Turning guidance off preserves the saved paragraph and tool access, and shows an
|
||||
amber notice: “Agents won’t be told when or how to use Honcho.” Disconnecting
|
||||
or losing access excludes both the guidance and tools from subsequent runs. A
|
||||
missing workspace, unavailable credential, or unsupported adapter gets an explicit
|
||||
“Instructions not applied” reason and a repair action; never a success badge.
|
||||
If required writes are Off or Ask first, say so and link to Permissions. Saving
|
||||
guidance must not change those policies or imply that writing is already allowed.
|
||||
|
||||
### 3. Inspect an agent
|
||||
|
||||
In the existing agent instructions area, add **From connections** beneath the
|
||||
agent's own files. Each row shows provider, short use summary, On/Off/unavailable,
|
||||
and its source. Expanding a row shows exactly what will be supplied and the
|
||||
effective binding for the viewed task context. **Manage in Honcho** returns to the
|
||||
connection editor. It is read-only here so the source of truth is unmistakable.
|
||||
|
||||
Distinguish configured guidance from effective guidance: a personal credential
|
||||
may be usable only for the responsible user's tasks. Without a task context,
|
||||
state this condition rather than promising unconditional application.
|
||||
|
||||
Memory remains an ordinary connector capability. Do not introduce a default-memory
|
||||
chooser, memory-specific agent preference, or first-class memory settings area.
|
||||
Connection instructions compose through the general connector instruction path;
|
||||
users manage each connection’s instructions and assignments in its configuration.
|
||||
|
||||
## Runtime and storage proposal
|
||||
|
||||
Implement only after the UX has been reviewed. Suggested contracts:
|
||||
|
||||
1. Add optional, versioned `agentGuidance` metadata to the AppDefinition source
|
||||
and schema: template ID/version, default text, and required context/capabilities. A nonempty
|
||||
template opts the connector into the UI; instructions start enabled. Regenerate
|
||||
the catalog from its authoring source; do not hand-edit generated JSON.
|
||||
2. Store company-scoped connection guidance with `enabled`, text, reviewed
|
||||
template version, revision, editor identity, and update time. Store provider
|
||||
context in a typed binding separate from prose; validate access on save and
|
||||
resolution. Keep public API/shared validators/db/UI contracts synchronized.
|
||||
3. Reuse the same effective access resolver as tool delivery: company, agent,
|
||||
task, responsible human, grant, connection health, and action policies all
|
||||
matter. Agent assignment alone must not imply access to a personal grant.
|
||||
4. Resolve a stable ordered list of instruction blocks per turn. Include source
|
||||
connection and revision, the short paragraph, and structured provider context.
|
||||
Use actual runtime tool aliases and current schemas for any detailed recipes.
|
||||
Do not paste the entire 40-tool Honcho catalog into the paragraph.
|
||||
5. Include a guidance digest in session compatibility/input composition. Native
|
||||
and resumed sessions must observe updates on the next turn. If a harness
|
||||
cannot replace old context, start a fresh session or display the limitation;
|
||||
never keep supplying conflicting old and new blocks indefinitely. Active
|
||||
calls remain governed by current gateway authorization on every invocation.
|
||||
6. Log edits and assignment changes as normal company activity. Record revision
|
||||
and binding references in local run evidence. No new first-party Telemetry is
|
||||
needed. Preview and run composition must share one resolver so they agree.
|
||||
|
||||
An enabled paragraph is user-controlled operating guidance, not authorization to
|
||||
bypass budgets, approvals, current user intent, or company boundaries. Templates
|
||||
are maintained by Paperclip; provider initialize instructions are external data
|
||||
that can inform a reviewed template, not an automatic template update. Pin saved
|
||||
versions; show “Suggested instructions updated” with a diff and deliberate apply.
|
||||
Never replace custom text during catalog refresh or reconnect.
|
||||
|
||||
### Memory context is not an access-control boundary
|
||||
|
||||
For the first Honcho slice, bind a dedicated workspace to the connection. Derive
|
||||
stable peer IDs from company + agent and session IDs from company + task; retries
|
||||
must reuse them. Decide these values in server code, not in editable prose. Show
|
||||
the workspace openly near the top of configuration; users should not need to
|
||||
invent peer IDs. Treat the task's responsible human as a separate subject when needed,
|
||||
not as interchangeable with the agent's peer.
|
||||
|
||||
A peer ID, tag, or query filter may partition retrieval without preventing an
|
||||
agent holding broad tools from reading another scope. Do not label that “private
|
||||
memory.” If private
|
||||
agent memory is promised later, enforce scope arguments server-side, disallow
|
||||
unscoped/list/bulk operations that bypass the boundary, and test alternate tool
|
||||
paths. Until then, use isolated provider credentials/workspaces for confidential
|
||||
scopes. Paperclip company access checks still apply to every connection.
|
||||
|
||||
## The smallest useful evaluation
|
||||
|
||||
Two separate questions: **does the integration work?** and **does the instruction
|
||||
make a small model use it appropriately?** Do not ask an LLM judge to score either.
|
||||
Assert tool calls, arguments, returned records, and a random fact in the answer.
|
||||
|
||||
Start with Honcho and Mem0. Each has a different memory shape, and both are already
|
||||
in the catalog. Expand to the other three once those fixtures are useful.
|
||||
|
||||
| Case | Setup / task | Pass evidence |
|
||||
| --- | --- | --- |
|
||||
| Discover (no model) | Authenticate disposable test connection and list tools through the actual run gateway | Required read/write tools visible; record schema hashes and policies |
|
||||
| Save → fresh recall | A: “Remember: release codename is `<random nonce>`.” B: fresh task/session, same memory subject: “What is the release codename?” | Confirmed write receipt; B performs retrieval before answering and includes the nonce from the returned record |
|
||||
| Implicit recall | Seed a synthetic formatting preference, then ask “Draft my weekly update” without mentioning memory | Guidance-on run retrieves and follows the seeded preference; record guidance-off control separately |
|
||||
| Skip irrelevant work | “What is 19 × 7?” with memory available | Correct answer, no memory calls or unnecessary writes |
|
||||
| Failed write / denied access | Return write failure; separately deny the grant | No “saved” claim; useful task continues; no fallback to another user's/company's memory |
|
||||
| Updated / disabled guidance (no model) | Change text, resume; turn off; unassign; revoke | Correct revision supplied once or absent as applicable; no agent file changes |
|
||||
|
||||
The fresh-recall reader receives neither the writer transcript nor its answer,
|
||||
nonce, runtime session, scratch files, or task summary. Disable other memory
|
||||
providers and fixture workspace-memory carryover. Give reader a fresh execution
|
||||
workspace. Only the provider contains the answer; the assertion runner knows the
|
||||
nonce. Same model instance/conversation is not sufficient isolation.
|
||||
|
||||
Run a deterministic gateway round trip before spending model tokens. For async
|
||||
ingestion, poll a documented status/read path with a bounded deadline (proposed
|
||||
60 seconds), record “indexing timeout” separately, and never sleep an arbitrary
|
||||
amount then declare the model wrong. For Honcho, first prove raw message retrieval;
|
||||
inferred conclusions are a separate, slower capability.
|
||||
|
||||
Use the existing Product E2E runner for through-Paperclip evidence; follow
|
||||
`add-product-e2e-eval` and `paperclip-evals` when implementing. Use a fixture MCP
|
||||
server in ordinary CI. Account-bound runs are opt-in; the fixture suite proves
|
||||
Paperclip behavior, not provider availability. Existing promptfoo response-text
|
||||
tests cannot substitute for the tool loop.
|
||||
|
||||
**Model candidates:** start with GPT-5 nano for the baseline, then Gemini 3.1
|
||||
Flash-Lite as the independent confirmation, if the configured runner provider
|
||||
supports them. They are inexpensive candidates, not models already proven on
|
||||
these connectors. Check the [OpenAI model page](https://developers.openai.com/api/docs/models/gpt-5-nano)
|
||||
and [Google pricing](https://ai.google.dev/gemini-api/docs/pricing) at execution
|
||||
time, including reasoning-token charges and any routing markup. If unavailable,
|
||||
select another small tool-capable model from the installed roster and record the
|
||||
substitution; do not expand this UX work into a new model adapter.
|
||||
|
||||
**Cost envelope:** begin with one small tool-capable API model supported
|
||||
by the runner, then one from another provider for confirmation. Select from the
|
||||
current installed roster/pricing at execution time, pin exact IDs in the report,
|
||||
and use a configurable `MEMORY_EVAL_MODEL` rather than “latest.” Do not treat
|
||||
“free” endpoints as reproducible CI. Initial matrix: 2 providers × 1 model × 4
|
||||
behavior cases; save/recall uses two isolated sessions per case. One run per cell,
|
||||
max 6 tool calls/session, 1,000 output tokens/session, concurrency 1. Proposed
|
||||
hard ceiling: **$1 model spend per matrix**, and **$0.10 per cell**; stop before
|
||||
dispatch if the reserved worst-case cost exceeds the remaining budget. Count
|
||||
input tokens/tool schemas too. No automatic retry of failed model cells.
|
||||
|
||||
Provider memory/derivation charges are separate: report them when available and
|
||||
otherwise mark them unknown, never $0. Use designated test accounts, synthetic
|
||||
facts, and a unique prefix. Cleanup only returned IDs or the known disposable
|
||||
session/workspace; do not run account-wide delete tools. If cleanup is unsupported
|
||||
or fails, report retained IDs and the required manual cleanup.
|
||||
|
||||
A report needs provider, endpoint/catalog hash, model ID, instruction revision,
|
||||
effective scope, per-case result, tool transcript/receipt IDs, latency, model
|
||||
usage/cost, provider cost availability, and cleanup status. Classify configuration,
|
||||
auth, policy, schema drift, ingestion, model behavior, and cleanup failures
|
||||
separately. A connected badge or model saying “I remembered” is not evidence.
|
||||
|
||||
## Delivery slices and acceptance
|
||||
|
||||
1. **This proposal:** interactive Storybook placement, editing/off/error states,
|
||||
agent preview, ordinary-connector examples with and without instructions, mobile/light views,
|
||||
and the connector playbook convention. All mutations are local simulation.
|
||||
2. **First runtime slice:** shared guidance contract, persistence, Honcho binding,
|
||||
optional UI within existing configuration, one prompt composer for native + local harnesses, meaningful
|
||||
assignment/revocation/resume tests. Provided templates default on while preserving
|
||||
explicit opt-outs; missing templates add no UI or runtime text.
|
||||
3. **Smoke slice:** deterministic gateway checks plus the bounded Honcho/Mem0
|
||||
model matrix above. Keep Experimental until both have fresh-session evidence;
|
||||
the other providers stay unverified until individually exercised.
|
||||
|
||||
Review in Storybook: **Design explorations → Connections → Agent instructions**.
|
||||
The specimens render the production app shell, setup flow, and connection detail
|
||||
page. The instructions block is composed into those pages through optional layout
|
||||
slots. Credential handling, persistence, bindings, and delivery receipts are simulated. No credentials
|
||||
or real memory providers are contacted. The plan intentionally does not ship a
|
||||
new memory service, transcript mirroring, memory browser, or automatic hooks.
|
||||
|
||||
## Prototype verification (2026-10-03)
|
||||
|
||||
- UI TypeScript check passed after building the shared and plugin SDK type
|
||||
dependencies in this fresh worktree.
|
||||
- Full Storybook static build passed. Existing CSS highlight/font-resolution and
|
||||
chunk-size warnings remain; no build errors.
|
||||
- All 14 new stories rendered in Chromium without page errors. Browser checks
|
||||
exercised edit/save/agent preview, disabling while retaining text, failed save
|
||||
and retry, and setup draft exit/resume.
|
||||
- Inspected desktop and 390-pixel mobile captures; no horizontal overflow.
|
||||
- Token gates, palette/character sync checks, and `git diff --check` passed.
|
||||
|
||||
In the initial proposal, no production code was changed. Repo-wide server tests/builds and account-bound
|
||||
memory/model evals were not run for this design-only change. Run the full PR
|
||||
checks before a PR-ready implementation handoff. This local Codex chat has no
|
||||
Paperclip issue/run identity, so issue artifact upload and work-product creation
|
||||
could not be associated with a destination. The plan and stories are retained in
|
||||
the repository; the built review is available through the local Storybook server.
|
||||
|
||||
## Revision: use the actual configuration pages (2026-10-04)
|
||||
|
||||
The initial specimens invented a header, tabs, and a wider setup form. Replaced
|
||||
them with the real `Layout`, `ConnectionSetupFlow`, and `AppDetail`, backed by
|
||||
Storybook API fixtures. Credential instructions, requirements links, the compact
|
||||
form width, Back/Connect footer, identity controls, agent access, and the searchable
|
||||
Actions list now come directly from production components. The new section lives
|
||||
under Permissions after agent access. Setup adds only the instruction section
|
||||
above the existing footer. No extra workspace input is added to setup.
|
||||
|
||||
`Current connect page` and `Current configuration page` render the same production
|
||||
pages without the proposed section, for direct comparison. The agent preview is
|
||||
a dialog over the connection page; it does not impersonate an implemented agent
|
||||
configuration page. The only production changes are optional composition slots;
|
||||
normal callers supply no new content and keep their current behavior.
|
||||
|
||||
Verification for this revision: UI TypeScript and the full Storybook build pass.
|
||||
All 242 tests across `AppDetail.test.tsx`, `AppsConnect.test.tsx`, and
|
||||
`ConnectionSetupFlow.architecture.test.ts` pass. Token gates and whitespace
|
||||
checks pass. Browser verification covered the actual setup and Permissions pages,
|
||||
edit/save/preview, disabling while preserving text, failed save and retry, and
|
||||
the 390-pixel mobile setup/editor (no horizontal overflow). Instruction saves
|
||||
remain Storybook-only; no runtime memory behavior or provider eval was run.
|
||||
|
||||
## Revision: concise provider-owned instructions (2026-10-05)
|
||||
|
||||
Apply the same instruction component across setup, settings, reconnect, and agent
|
||||
preview. Use “Tell agents to use {provider}”, default on for explicitly supplied
|
||||
templates, an amber off state, and “Reset to default” after changes. Remove the
|
||||
suggestion badge, storage/lifecycle explanation, and preview disclaimers from
|
||||
product UI; prototype limitations stay in Storybook documentation. Honcho’s
|
||||
required workspace stays expanded at the top, with validation beside the field.
|
||||
Remove the proposed default-memory chooser from both the stories and runtime plan.
|
||||
|
||||
The shared setup header no longer shows the generic “Connect now” subtitle.
|
||||
Remove the Honcho key-permission paragraph from its catalog authoring source and
|
||||
regenerate the definition so all credential forms pick up the same change. The
|
||||
shared credential form renders only provider-supplied helper text, without
|
||||
substituting the removed generic key-permission paragraph.
|
||||
|
||||
Verification: 296 targeted tests pass across connection setup/detail, setup
|
||||
architecture, app definitions, and memory connector risk coverage. After removing
|
||||
the fallback credential paragraph, all 174 setup/architecture tests pass again.
|
||||
Browser checks confirm template-present/on and template-absent/hidden states,
|
||||
reset after saving a custom paragraph, off-state warning and preserved text,
|
||||
failed save/retry, workspace-required blocking, baseline copy, and the mobile form
|
||||
at 390 pixels without horizontal overflow. Final UI typecheck, full Storybook
|
||||
build, token gates, and whitespace checks pass. These remain simulated instruction
|
||||
and workspace settings; no live provider or model calls were made.
|
||||
@@ -2245,6 +2245,7 @@ export function renderPaperclipWakePrompt(
|
||||
return joinPromptSections([
|
||||
renderPaperclipWakePromptBody(value, options),
|
||||
instructions ? `## Assigned connector skills\n\n${instructions}` : "",
|
||||
asString(parseObject(parseObject(value).connectionInstructions).text, "").trim(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "agent_instructions" jsonb;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Honcho to recall relevant preferences and prior decisions when work depends on earlier context. Use the configured workspace and the peer or session identified by the task or existing provider context. Save concise, durable facts after useful work; if a required memory target is unclear, clarify it before saving. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "honcho.usage", "version": 1}}'::jsonb
|
||||
WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'honcho';
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Mem0 to search for relevant preferences, decisions, and prior work before relying on remembered context. Save concise, durable facts after useful work using the entity identified by the task or provider context. Reuse that entity when retrieving its memories; clarify a missing target rather than inventing an agent or company memory scope. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "mem0.usage", "version": 1}}'::jsonb
|
||||
WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'mem0';
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Zep to search the connected identity’s memory when earlier decisions or preferences matter. Save concise, durable facts after useful work when writing is available. Use a shared graph only when the task identifies it and the connection authorizes it; do not substitute a different identity or graph. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "zep.usage", "version": 1}}'::jsonb
|
||||
WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'zep';
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Supermemory to recall relevant preferences, decisions, and prior work from the workspace and tags authorized for this connection. Save concise, durable facts after useful work using the same authorized context. Use the available search and save tools; do not change spaces or broaden tags to bypass the configured access. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "supermemory.usage", "version": 1}}'::jsonb
|
||||
WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'supermemory';
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Cognee recall to retrieve relevant facts and decisions when work depends on earlier context. Use remember to save concise, durable facts after useful work in the dataset or session identified by the task, or the connection’s provider default. Use forget only when the task calls for deletion. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "cognee.usage", "version": 1}}'::jsonb
|
||||
WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'cognee';
|
||||
+455
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "d0c40f4e-c2c5-4efd-bc15-c6ae5632047d",
|
||||
"prevId": "239dfc30-3531-48b5-a06f-72608de479da",
|
||||
"id": "792dfb38-10bf-4147-a541-5c52ce0e80b6",
|
||||
"prevId": "ed75c75c-748b-4420-8a68-ec5b34394e75",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -38918,6 +38918,140 @@
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.resource_lifecycle_events": {
|
||||
"name": "resource_lifecycle_events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "resource_lifecycle_events_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "9223372036854775807",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"resource_lifecycle_events_creation_idx": {
|
||||
"name": "resource_lifecycle_events_creation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "resource_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "resource_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"resource_lifecycle_events\".\"action\" = 'create'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"resource_lifecycle_events_company_idx": {
|
||||
"name": "resource_lifecycle_events_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"resource_lifecycle_events_company_id_companies_id_fk": {
|
||||
"name": "resource_lifecycle_events_company_id_companies_id_fk",
|
||||
"tableFrom": "resource_lifecycle_events",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"resource_lifecycle_events_resource_type_check": {
|
||||
"name": "resource_lifecycle_events_resource_type_check",
|
||||
"value": "\"resource_lifecycle_events\".\"resource_type\" IN ('agent', 'project')"
|
||||
},
|
||||
"resource_lifecycle_events_action_check": {
|
||||
"name": "resource_lifecycle_events_action_check",
|
||||
"value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.routine_documents": {
|
||||
"name": "routine_documents",
|
||||
"schema": "",
|
||||
@@ -44497,6 +44631,298 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tool_connection_app_snapshots": {
|
||||
"name": "tool_connection_app_snapshots",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"connection_id": {
|
||||
"name": "connection_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"credential_key": {
|
||||
"name": "credential_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"toolkit": {
|
||||
"name": "toolkit",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"accounts": {
|
||||
"name": "accounts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"checked_at": {
|
||||
"name": "checked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"error_at": {
|
||||
"name": "error_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tool_connection_app_snapshots_owner_toolkit_uq": {
|
||||
"name": "tool_connection_app_snapshots_owner_toolkit_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "connection_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "toolkit",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tool_connection_app_snapshots_company_id_companies_id_fk": {
|
||||
"name": "tool_connection_app_snapshots_company_id_companies_id_fk",
|
||||
"tableFrom": "tool_connection_app_snapshots",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tool_connection_app_snapshots_company_connection_fk": {
|
||||
"name": "tool_connection_app_snapshots_company_connection_fk",
|
||||
"tableFrom": "tool_connection_app_snapshots",
|
||||
"tableTo": "tool_connections",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"connection_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tool_connection_app_syncs": {
|
||||
"name": "tool_connection_app_syncs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"connection_id": {
|
||||
"name": "connection_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"credential_key": {
|
||||
"name": "credential_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"lease_id": {
|
||||
"name": "lease_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"checked": {
|
||||
"name": "checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"total": {
|
||||
"name": "total",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"failed": {
|
||||
"name": "failed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"last_completed_at": {
|
||||
"name": "last_completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tool_connection_app_syncs_owner_key_uq": {
|
||||
"name": "tool_connection_app_syncs_owner_key_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "connection_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "credential_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tool_connection_app_syncs_company_id_companies_id_fk": {
|
||||
"name": "tool_connection_app_syncs_company_id_companies_id_fk",
|
||||
"tableFrom": "tool_connection_app_syncs",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tool_connection_app_syncs_company_connection_fk": {
|
||||
"name": "tool_connection_app_syncs_company_connection_fk",
|
||||
"tableFrom": "tool_connection_app_syncs",
|
||||
"tableTo": "tool_connections",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"connection_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tool_connection_installs": {
|
||||
"name": "tool_connection_installs",
|
||||
"schema": "",
|
||||
@@ -44790,6 +45216,12 @@
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"agent_instructions": {
|
||||
"name": "agent_instructions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
@@ -46209,6 +46641,27 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tool_mcp_gateway_tokens_expiry_idx": {
|
||||
"name": "tool_mcp_gateway_tokens_expiry_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tool_mcp_gateway_tokens_gateway_idx": {
|
||||
"name": "tool_mcp_gateway_tokens_gateway_idx",
|
||||
"columns": [
|
||||
+7
@@ -2073,6 +2073,13 @@
|
||||
"when": 1791233758328,
|
||||
"tag": "0297_foamy_swordsman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 298,
|
||||
"version": "7",
|
||||
"when": 1791241297512,
|
||||
"tag": "0298_connection_agent_instructions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type {
|
||||
ConnectionAgentInstructions,
|
||||
ConnectionTokenIssuanceOutcome,
|
||||
ConnectionTokenIssuancePath,
|
||||
McpConnectionCredentialRef,
|
||||
@@ -129,6 +130,7 @@ export const toolConnections = pgTable(
|
||||
credentialPolicy: text("credential_policy").$type<ToolConnectionCredentialPolicy>().notNull().default("shared"),
|
||||
status: text("status").$type<ToolConnectionStatus>().notNull().default("draft"),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
agentInstructions: jsonb("agent_instructions").$type<ConnectionAgentInstructions>(),
|
||||
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
transportConfig: jsonb("transport_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
credentialRefs: jsonb("credential_refs").$type<McpConnectionCredentialRef[]>().notNull().default([]),
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface NativeRuntimeContextSnapshot {
|
||||
};
|
||||
skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>;
|
||||
mcp: { assignmentSetId: string; digest: string; bindingId: string | null };
|
||||
connectionInstructions?: { text: string; digest: string };
|
||||
aggregateDigest: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +88,7 @@ function aggregatePayload(value: Omit<NativeRuntimeContextSnapshot, "aggregateDi
|
||||
// assigned access set so a fresh capability can be rebound without forcing a
|
||||
// provider-session rotation when policy has not changed.
|
||||
mcp: { assignmentSetId: value.mcp.assignmentSetId, digest: value.mcp.digest },
|
||||
...(value.connectionInstructions ? { connectionInstructions: value.connectionInstructions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ export function nativeRuntimePromptDigest(): string { return sha256(PAPERCLIP_EX
|
||||
|
||||
export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextSnapshot {
|
||||
const context = object(value, "input.runtimeContext");
|
||||
exact(context, ["prompt", "instructions", "skills", "mcp", "aggregateDigest"], "input.runtimeContext");
|
||||
exact(context, ["prompt", "instructions", "skills", "mcp", "connectionInstructions", "aggregateDigest"], "input.runtimeContext");
|
||||
const prompt = object(context.prompt, "input.runtimeContext.prompt");
|
||||
exact(prompt, ["revision", "text", "digest"], "input.runtimeContext.prompt");
|
||||
if (prompt.revision !== PAPERCLIP_EXECUTION_PROMPT_REVISION || prompt.text !== PAPERCLIP_EXECUTION_PROMPT) {
|
||||
@@ -128,6 +130,15 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
|
||||
}
|
||||
const mcp = object(context.mcp, "input.runtimeContext.mcp");
|
||||
exact(mcp, ["assignmentSetId", "digest", "bindingId"], "input.runtimeContext.mcp");
|
||||
let connectionInstructions: NativeRuntimeContextSnapshot["connectionInstructions"];
|
||||
if (context.connectionInstructions !== undefined) {
|
||||
const block = object(context.connectionInstructions, "input.runtimeContext.connectionInstructions");
|
||||
exact(block, ["text", "digest"], "input.runtimeContext.connectionInstructions");
|
||||
const content = text(block.text, "input.runtimeContext.connectionInstructions.text");
|
||||
const contentDigest = digest(block.digest, "input.runtimeContext.connectionInstructions.digest");
|
||||
if (sha256(content) !== contentDigest) throw new NativeRuntimeContextError("Connection instruction digest does not match text");
|
||||
connectionInstructions = { text: content, digest: contentDigest };
|
||||
}
|
||||
const parsed = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: {
|
||||
@@ -145,6 +156,7 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
|
||||
digest: digest(mcp.digest, "input.runtimeContext.mcp.digest"),
|
||||
bindingId: mcp.bindingId === null ? null : text(mcp.bindingId, "input.runtimeContext.mcp.bindingId"),
|
||||
},
|
||||
...(connectionInstructions ? { connectionInstructions } : {}),
|
||||
} satisfies Omit<NativeRuntimeContextSnapshot, "aggregateDigest">;
|
||||
const aggregateDigest = digest(context.aggregateDigest, "input.runtimeContext.aggregateDigest");
|
||||
if (aggregateDigest !== canonicalNativeRuntimeContextDigest(parsed)) {
|
||||
@@ -157,6 +169,7 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna
|
||||
return [
|
||||
context.prompt.text,
|
||||
entryContent.trim(),
|
||||
context.connectionInstructions?.text,
|
||||
context.instructions.workingCopy?.kind === "agent_files"
|
||||
? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.`
|
||||
: context.instructions.workingCopy
|
||||
|
||||
@@ -1026,6 +1026,7 @@ describe("AppDefinition catalog", () => {
|
||||
).sort();
|
||||
expect(required).toEqual([
|
||||
"clickhouse:mcp-oauth:serviceId",
|
||||
"honcho:mcp-api-key:workspaceId",
|
||||
"shopify:storefront-mcp:storeDomain",
|
||||
"shopify:ucp-commerce:storeDomain",
|
||||
"supabase:mcp-api-key:projectRef",
|
||||
|
||||
@@ -67,5 +67,10 @@
|
||||
"docs": "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"agentInstructions": {
|
||||
"id": "cognee.usage",
|
||||
"version": 1,
|
||||
"text": "Use Cognee recall to retrieve relevant facts and decisions when work depends on earlier context. Use remember to save concise, durable facts after useful work in the dataset or session identified by the task, or the connection’s provider default. Use forget only when the task calls for deletion. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable."
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,7 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Honcho API key",
|
||||
"secret": true,
|
||||
"helperMd": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
"secret": true
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
@@ -51,7 +50,24 @@
|
||||
},
|
||||
"warnings": [
|
||||
"An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools."
|
||||
],
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "workspaceId",
|
||||
"label": "Honcho workspace",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "Workspace ID",
|
||||
"validation": {
|
||||
"maxLength": 512
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"agentInstructions": {
|
||||
"id": "honcho.usage",
|
||||
"version": 1,
|
||||
"text": "Use Honcho to recall relevant preferences and prior decisions when work depends on earlier context. Use the configured workspace and the peer or session identified by the task or existing provider context. Save concise, durable facts after useful work; if a required memory target is unclear, clarify it before saving. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable."
|
||||
}
|
||||
}
|
||||
@@ -53,5 +53,10 @@
|
||||
"A Mem0 API key; the live server currently requires the slash-normalized endpoint."
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"agentInstructions": {
|
||||
"id": "mem0.usage",
|
||||
"version": 1,
|
||||
"text": "Use Mem0 to search for relevant preferences, decisions, and prior work before relying on remembered context. Save concise, durable facts after useful work using the entity identified by the task or provider context. Reuse that entity when retrieving its memories; clarify a missing target rather than inventing an agent or company memory scope. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable."
|
||||
}
|
||||
}
|
||||
@@ -46,5 +46,10 @@
|
||||
"user"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"agentInstructions": {
|
||||
"id": "supermemory.usage",
|
||||
"version": 1,
|
||||
"text": "Use Supermemory to recall relevant preferences, decisions, and prior work from the workspace and tags authorized for this connection. Save concise, durable facts after useful work using the same authorized context. Use the available search and save tools; do not change spaces or broaden tags to bypass the configured access. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable."
|
||||
}
|
||||
}
|
||||
@@ -45,5 +45,10 @@
|
||||
"user"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"agentInstructions": {
|
||||
"id": "zep.usage",
|
||||
"version": 1,
|
||||
"text": "Use Zep to search the connected identity’s memory when earlier decisions or preferences matter. Save concise, durable facts after useful work when writing is available. Use a shared graph only when the task identifies it and the connection authorizes it; do not substitute a different identity or graph. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { connectionAgentInstructionsSchema, connectionInstructionContext, defaultConnectionAgentInstructions } from "./connection-instructions.js";
|
||||
import { getConnectableAppDefinition } from "./app-definitions.js";
|
||||
import { connectToolAppSchema, createToolConnectionSchema, finishToolAppSchema, updateToolConnectionSchema } from "./validators/tool-access.js";
|
||||
import { appDefinitionSchema } from "./validators/app-definition.js";
|
||||
|
||||
describe("generic connection instructions", () => {
|
||||
it.each(["honcho", "mem0", "zep", "supermemory", "cognee"])("validates and enables the reviewed %s default", (slug) => {
|
||||
const app = appDefinitionSchema.parse(getConnectableAppDefinition(slug));
|
||||
const settings = connectionAgentInstructionsSchema.parse(defaultConnectionAgentInstructions(app.agentInstructions));
|
||||
expect(settings.enabled).toBe(true);
|
||||
expect(settings.template?.version).toBeGreaterThan(0);
|
||||
expect(settings.text).toContain("current task");
|
||||
expect(settings.text).toContain("approvals");
|
||||
});
|
||||
it("supports non-memory templates and saved settings without a template", () => {
|
||||
const app = { ...getConnectableAppDefinition("notion")!, agentInstructions: { id: "handbook", version: 1, text: "Consult the current release checklist." } };
|
||||
expect(appDefinitionSchema.parse(app).agentInstructions).toEqual(app.agentInstructions);
|
||||
const value = { enabled: false, text: "Keep this custom paragraph." };
|
||||
expect(updateToolConnectionSchema.parse({ agentInstructions: value }).agentInstructions).toEqual(value);
|
||||
expect(connectToolAppSchema.parse({ galleryKey: "notion", agentInstructions: value }).agentInstructions).toEqual(value);
|
||||
expect(finishToolAppSchema.parse({ enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents", agentInstructions: value }).agentInstructions).toEqual(value);
|
||||
expect(createToolConnectionSchema.parse({ name: "Handbook", transport: "mcp_remote", agentInstructions: value }).agentInstructions).toEqual(value);
|
||||
expect(defaultConnectionAgentInstructions(getConnectableAppDefinition("notion")?.agentInstructions)).toBeNull();
|
||||
});
|
||||
it("withholds unconfigured instructions and never includes secret fields", () => {
|
||||
const app = getConnectableAppDefinition("honcho")!;
|
||||
expect(connectionInstructionContext(app, { methodConfig: { workspaceId: " " } })).toBeNull();
|
||||
expect(connectionInstructionContext(app, { methodConfig: { workspaceId: " team ", apiKey: "secret" } })).toEqual({ workspaceId: "team" });
|
||||
});
|
||||
it("allows explicit clearing and rejects blank or unbounded text", () => {
|
||||
expect(updateToolConnectionSchema.parse({ agentInstructions: null }).agentInstructions).toBeNull();
|
||||
expect(connectionAgentInstructionsSchema.safeParse({ enabled: true, text: " " }).success).toBe(false);
|
||||
expect(connectionAgentInstructionsSchema.safeParse({ enabled: true, text: "a".repeat(2001) }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("regenerates templates from each app definition, including non-memory edits and removal", () => {
|
||||
const root = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const fixture = mkdtempSync(join(tmpdir(), "paperclip-app-instructions-"));
|
||||
const definitions = join(fixture, "packages/shared/src/app-definitions");
|
||||
const readApp = (slug: string) => JSON.parse(readFileSync(join(definitions, `${slug}.json`), "utf8"));
|
||||
const writeApp = (slug: string, app: unknown) => writeFileSync(join(definitions, `${slug}.json`), JSON.stringify(app));
|
||||
const generate = () => execFileSync(process.execPath, [join(root, "scripts/ingest-app-definitions.mjs"), "--definitions-only"], { cwd: fixture, stdio: "pipe" });
|
||||
try {
|
||||
for (const source of [
|
||||
"ui/public/brands/apps/manifest.json",
|
||||
"packages/shared/src/self-serve-mcp-research.json",
|
||||
"doc/connections/tool-method-permission-reviews.json",
|
||||
"packages/shared/src/app-definitions",
|
||||
]) {
|
||||
mkdirSync(dirname(join(fixture, source)), { recursive: true });
|
||||
cpSync(join(root, source), join(fixture, source), { recursive: true });
|
||||
}
|
||||
const notion = readApp("notion");
|
||||
notion.agentInstructions = { id: "notion.handbook", version: 2, text: "Consult the current release checklist." };
|
||||
writeApp("notion", notion);
|
||||
generate();
|
||||
expect(readApp("notion").agentInstructions).toEqual(notion.agentInstructions);
|
||||
for (const slug of ["honcho", "mem0", "zep", "supermemory", "cognee"]) {
|
||||
expect(readApp(slug).agentInstructions).toEqual(getConnectableAppDefinition(slug)?.agentInstructions);
|
||||
}
|
||||
expect(readApp("linear").agentInstructions).toBeUndefined();
|
||||
|
||||
notion.agentInstructions.text = "Check the updated handbook before a release.";
|
||||
notion.agentInstructions.version++;
|
||||
writeApp("notion", notion);
|
||||
const mem0 = readApp("mem0");
|
||||
delete mem0.agentInstructions;
|
||||
writeApp("mem0", mem0);
|
||||
generate();
|
||||
expect(readApp("notion").agentInstructions).toEqual(notion.agentInstructions);
|
||||
expect(readApp("mem0").agentInstructions).toBeUndefined();
|
||||
|
||||
notion.agentInstructions.text = " ";
|
||||
writeApp("notion", notion);
|
||||
expect(generate).toThrow("notion: invalid agent instruction template");
|
||||
} finally {
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import type { AppDefinition } from "./types/app-definition.js";
|
||||
|
||||
export const CONNECTION_INSTRUCTIONS_MAX_LENGTH = 2000;
|
||||
|
||||
/** Optional catalog capability. It is independent of provider and transport. */
|
||||
export const connectionInstructionTemplateSchema = z.object({
|
||||
id: z.string().trim().min(1).max(160),
|
||||
version: z.number().int().positive(),
|
||||
text: z.string().trim().min(1).max(CONNECTION_INSTRUCTIONS_MAX_LENGTH),
|
||||
});
|
||||
export type ConnectionInstructionTemplate = z.infer<typeof connectionInstructionTemplateSchema>;
|
||||
|
||||
/** Connection-owned settings; custom connections need not have a template. */
|
||||
export const connectionAgentInstructionsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
text: z.string().trim().min(1).max(CONNECTION_INSTRUCTIONS_MAX_LENGTH),
|
||||
template: z.object({
|
||||
id: z.string().trim().min(1).max(160),
|
||||
version: z.number().int().positive(),
|
||||
}).optional(),
|
||||
});
|
||||
export type ConnectionAgentInstructions = z.infer<typeof connectionAgentInstructionsSchema>;
|
||||
|
||||
export function defaultConnectionAgentInstructions(
|
||||
template: ConnectionInstructionTemplate | null | undefined,
|
||||
): ConnectionAgentInstructions | null {
|
||||
return template ? {
|
||||
enabled: true,
|
||||
text: template.text,
|
||||
template: { id: template.id, version: template.version },
|
||||
} : null;
|
||||
}
|
||||
|
||||
/** The server supplies this immutable snapshot, never the wake request author. */
|
||||
export interface ConnectionInstructionsSnapshot {
|
||||
text: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
/** Older connections can retain catalog metadata only in transportConfig. */
|
||||
export function connectionInstructionsConfig(connection: {
|
||||
config?: Record<string, unknown> | null;
|
||||
transportConfig?: Record<string, unknown> | null;
|
||||
}): Record<string, unknown> {
|
||||
return { ...connection.transportConfig, ...connection.config };
|
||||
}
|
||||
|
||||
/** Only public configuration fields declared by the connector enter prompts. */
|
||||
export function connectionInstructionContext(
|
||||
app: AppDefinition | null | undefined,
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, string | boolean> | null {
|
||||
const method = app?.methods.find((entry) => entry.key === config.connectionMethodKey) ?? app?.methods[0];
|
||||
const values = config.methodConfig && typeof config.methodConfig === "object" && !Array.isArray(config.methodConfig)
|
||||
? config.methodConfig as Record<string, unknown> : {};
|
||||
const context: Record<string, string | boolean> = {};
|
||||
for (const field of [...(method?.tenantFields ?? []), ...(method?.extensionFields ?? [])]) {
|
||||
if (field.secret || field.type === "password") continue;
|
||||
const value = values[field.key] ?? field.defaultValue;
|
||||
if (field.required && (value === undefined || value === null || (typeof value === "string" && !value.trim()))) return null;
|
||||
if (typeof value === "string" && value.trim()) context[field.key] = value.trim();
|
||||
else if (typeof value === "boolean") context[field.key] = value;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -2821,3 +2821,5 @@ export * from "./types/skill-source.js";
|
||||
export * from "./validators/skill-source.js";
|
||||
export * from "./github-skill-repository.js";
|
||||
export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema, type AggregatorAppSnapshot, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "./aggregator-apps.js";
|
||||
|
||||
export * from "./connection-instructions.js";
|
||||
@@ -3,7 +3,7 @@ export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|
|
||||
export type OAuthRedirectConstraints = "https-or-loopback-http";
|
||||
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
|
||||
export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
|
||||
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
|
||||
export interface AppDefinition { agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
|
||||
|
||||
export type SelfServeMcpAuthMode =
|
||||
| "dcr"
|
||||
|
||||
@@ -165,6 +165,7 @@ export interface ToolApplication {
|
||||
}
|
||||
|
||||
export interface ToolConnection {
|
||||
agentInstructions?: import("../connection-instructions.js").ConnectionAgentInstructions | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
applicationId: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { connectionInstructionTemplateSchema } from "../connection-instructions.js";
|
||||
import { AI_CONNECTION_CAPABILITIES, aiConnectionMetadataSchema } from "../ai-connections.js";
|
||||
import { connectionGrantKindSchema, toolConnectionOwnershipSchema, toolConnectionPurposeSchema, toolConnectionTransportSchema } from "./tool-access.js";
|
||||
const appBrandAssetUrlSchema=z.string().refine((value)=>{
|
||||
@@ -7,5 +8,5 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
|
||||
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
|
||||
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",mLine truncated
|
||||
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
|
||||
export const appDefinitionSchema=z.object({agentInstructions:connectionInstructionTemplateSchema.optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
|
||||
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connectionAgentInstructionsSchema } from "../connection-instructions.js";
|
||||
import { isRemoteMcpConnectorMethod } from "../remote-mcp-connectors.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -178,6 +179,7 @@ export const updateToolApplicationSchema = createToolApplicationSchema.partial()
|
||||
export type UpdateToolApplication = z.infer<typeof updateToolApplicationSchema>;
|
||||
|
||||
export const createToolConnectionSchema = z.object({
|
||||
agentInstructions: connectionAgentInstructionsSchema.nullable().optional(),
|
||||
applicationId: z.string().guid().optional(),
|
||||
applicationName: z.string().trim().min(1).max(160).optional(),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
@@ -407,6 +409,7 @@ function rejectUnsafeHeaderCredentials(
|
||||
}
|
||||
|
||||
export const connectToolAppSchema = z.object({
|
||||
agentInstructions: connectionAgentInstructionsSchema.nullable().optional(),
|
||||
galleryKey: z.string().trim().min(1).max(120).optional(),
|
||||
connectionMethodKey: z.string().trim().min(1).max(120).optional(),
|
||||
link: z.string().trim().url().max(2000).optional(),
|
||||
@@ -488,6 +491,7 @@ export const reconnectToolAppSchema = z.object({
|
||||
export type ReconnectToolApp = z.infer<typeof reconnectToolAppSchema>;
|
||||
|
||||
export const finishToolAppSchema = z.object({
|
||||
agentInstructions: connectionAgentInstructionsSchema.nullable().optional(),
|
||||
/** Task setup adds access while preserving existing assignments and action policies. */
|
||||
preserveExistingAccess: z.boolean().optional(),
|
||||
enabledCatalogEntryIds: z.array(z.string().guid()).max(500).default([]),
|
||||
|
||||
@@ -1130,6 +1130,10 @@ const specialMethodsFor = (entry) => {
|
||||
apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, {
|
||||
guidanceMd: `Open the ${entry.name} dashboard, create an API key for the account agents should use, and paste it below.`,
|
||||
consoleLinks: { keys: entry.slug === "mem0" ? "https://app.mem0.ai/dashboard/api-keys" : "https://app.honcho.dev", docs: entry.docsUrl },
|
||||
...(entry.slug === "honcho" ? { tenantFields: [{
|
||||
key: "workspaceId", label: "Honcho workspace", type: "text", required: true,
|
||||
placeholder: "Workspace ID", validation: { maxLength: 512 },
|
||||
}] } : {}),
|
||||
}),
|
||||
];
|
||||
if (entry.slug === "zep") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
|
||||
@@ -1755,6 +1759,21 @@ for (const app of apps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reviewed instruction templates are authored in each app's definition. Keep
|
||||
// that optional capability intact when regenerating its transport/auth fields.
|
||||
for (const app of apps) {
|
||||
const definitionPath = path.join(out, `${app.slug}.json`);
|
||||
if (!fs.existsSync(definitionPath)) continue;
|
||||
const { agentInstructions: template } = JSON.parse(fs.readFileSync(definitionPath, "utf8"));
|
||||
if (template === undefined) continue;
|
||||
if (!template || typeof template.id !== "string" || !template.id.trim() || template.id.length > 160
|
||||
|| !Number.isInteger(template.version) || template.version < 1
|
||||
|| typeof template.text !== "string" || !template.text.trim() || template.text.length > 2000) {
|
||||
throw new Error(`${app.slug}: invalid agent instruction template`);
|
||||
}
|
||||
app.agentInstructions = template;
|
||||
}
|
||||
|
||||
const validateApp = (app) => {
|
||||
if (
|
||||
app.schemaVersion !== 1 ||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { nativeSystemInstructions } from "../../../packages/paperclip-runner/src/backends/runtime-context.js";
|
||||
import type { NativeExecutionInput } from "../vendor/paperclip-runner/index.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, mkdtemp, writeFile, rm, readFile } from "node:fs/promises";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, companyMemberships, connectionGrants, createDb, heartbeatRuns, issues, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfileBindings, toolActionRequests } from "@paperclipai/db";
|
||||
import { createToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { toolDiscoveryScheduler } from "../services/tool-discovery-scheduler.js";
|
||||
import { toolAccessService, projectedConnectionToolArguments, projectedConnectionToolInputSchema } from "../services/tool-access.js";
|
||||
import { composeConnectionInstructions, prepareConnectionInstructionDelivery } from "../services/connection-instructions.js";
|
||||
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { canonicalNativeRuntimeContextDigest, parseNativeRuntimeContext } from "../vendor/paperclip-runner/index.js";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
import { execute as executeProcess } from "../adapters/process/execute.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const paragraph = "Use the release handbook before changing a deployment. Cite the relevant release checklist.";
|
||||
async function createRunFixture(db: ReturnType<typeof createDb>) {
|
||||
const company = await db.insert(companies).values({
|
||||
name: `Gateway ${randomUUID()}`,
|
||||
issuePrefix: `TG${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const agent = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: `Gateway Agent ${randomUUID()}`,
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const issue = await db.insert(issues).values({
|
||||
companyId: company.id,
|
||||
title: "Gateway approval work",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agent.id,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const run = await db.insert(heartbeatRuns).values({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
invocationSource: "assignment",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: issue.id },
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
return { company, agent, issue, run };
|
||||
}
|
||||
|
||||
async function createRemoteMcpToolFixture(db: ReturnType<typeof createDb>, companyId: string) {
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId,
|
||||
applicationKey: `remote-${randomUUID().slice(0, 8)}`,
|
||||
name: "Remote MCP",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId,
|
||||
applicationId: application.id,
|
||||
name: "Remote connection",
|
||||
uid: `test/${randomUUID()}`,
|
||||
transport: "mcp_remote",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "ok",
|
||||
// Use a public IP literal so protocol tests remain independent of DNS while
|
||||
// still exercising the production egress guard and their global fetch stub.
|
||||
credentialPolicy: "shared",
|
||||
config: { url: "https://8.8.8.8/mcp" },
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
});
|
||||
const catalogEntry = await db.insert(toolCatalogEntries).values({
|
||||
companyId,
|
||||
applicationId: application.id,
|
||||
connectionId: connection.id,
|
||||
entryKind: "tool",
|
||||
name: "needs_input",
|
||||
toolName: "needs_input",
|
||||
title: "Needs input",
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
status: "active",
|
||||
versionHash: randomUUID(),
|
||||
schemaHash: randomUUID(),
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
return { application, connection, catalogEntry };
|
||||
}
|
||||
|
||||
|
||||
(support.supported ? describe : describe.skip)("generic connection instruction delivery", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => { temp = await startEmbeddedPostgresTestDatabase("paperclip-connection-instructions-"); db = createDb(temp.connectionString); }, 30000);
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
async function fixture() {
|
||||
const run = await createRunFixture(db);
|
||||
const connection = await createRemoteMcpToolFixture(db, run.company.id);
|
||||
const service = toolAccessService(db);
|
||||
await service.updateConnection(connection.connection.id, { agentInstructions: { enabled: true, text: paragraph } });
|
||||
await service.finishGalleryAppConnection(run.company.id, connection.connection.id, {
|
||||
enabledCatalogEntryIds: [connection.catalogEntry.id], askFirstCatalogEntryIds: [], access: { agentIds: [run.agent.id] },
|
||||
});
|
||||
const gateway = createToolGatewayService(db);
|
||||
const binding = { companyId: run.company.id, agentId: run.agent.id, runId: run.run.id };
|
||||
return { ...run, ...connection, gateway, service, binding, resolve: () => gateway.resolveConnectionInstructionsForRun(binding) };
|
||||
}
|
||||
|
||||
it("keeps guidance stable and checks revocation even when the discovery queue is saturated", async () => {
|
||||
const f = await fixture();
|
||||
const snapshot = await f.resolve();
|
||||
expect(snapshot?.text).toContain(paragraph);
|
||||
let release!: () => void;
|
||||
const barrier = new Promise<void>((resolve) => { release = resolve; });
|
||||
const listings = Array.from({ length: 34 }, () => toolDiscoveryScheduler.run(() => barrier));
|
||||
try {
|
||||
expect(await f.resolve()).toEqual(snapshot);
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, f.connection.id));
|
||||
expect(await f.resolve()).toBeNull();
|
||||
} finally {
|
||||
release();
|
||||
await Promise.all(listings);
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a non-memory paragraph and captures it in actual process input and both prompt composers", async () => {
|
||||
const f = await fixture();
|
||||
expect((await f.service.getConnection(f.connection.id)).agentInstructions).toEqual({ enabled: true, text: paragraph });
|
||||
const context: Record<string, unknown> = { connectionInstructions: { text: "forged" }, paperclipWake: { connectionInstructions: { text: "forged wake" } } };
|
||||
const config = await prepareConnectionInstructionDelivery({ resolve: f.resolve, context, config: { paperclipConnectionInstructions: { text: "forged config" } }, native: false });
|
||||
const snapshot = config.paperclipConnectionInstructions as { text: string; digest: string };
|
||||
expect(snapshot.text).toContain(paragraph);
|
||||
expect(snapshot.text).not.toContain("forged");
|
||||
expect(renderPaperclipWakePrompt(context.paperclipWake)).toContain(paragraph);
|
||||
const runtimeContext = { ...nativeRuntimeContextFixture(), connectionInstructions: snapshot };
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const parsed = parseNativeRuntimeContext(runtimeContext);
|
||||
const bundle = await mkdtemp(join(tmpdir(), "connection-instruction-input-"));
|
||||
try {
|
||||
await writeFile(join(bundle, "AGENTS.md"), "Agent entry instructions.");
|
||||
parsed.instructions.bundle.rootPath = bundle;
|
||||
expect(nativeSystemInstructions({ runtimeContext: parsed } as NativeExecutionInput)).toContain(paragraph);
|
||||
} finally { await rm(bundle, { recursive: true, force: true }); }
|
||||
expect(() => parseNativeRuntimeContext({ ...runtimeContext, connectionInstructions: { ...snapshot, text: "forged" } })).toThrow();
|
||||
expect(parseNativeRuntimeContext(nativeRuntimeContextFixture()).connectionInstructions).toBeUndefined();
|
||||
const captured: string[] = [];
|
||||
const result = await executeProcess({ runId: f.run.id, agent: { ...f.agent, adapterType: "process" }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, context,
|
||||
config: { command: process.execPath, args: ["-e", "const fs=require('node:fs');const file=process.env.PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE;console.log(JSON.stringify({file,mode:fs.statSync(file).mode&511,snapshot:JSON.parse(fs.readFileSync(file,'utf8'))}))"], env: { PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE: "/forged" } },
|
||||
onLog: async (_stream, text) => { captured.push(text); },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const received = JSON.parse(captured.join("").trim());
|
||||
expect(received.snapshot).toEqual(snapshot);
|
||||
expect(received.mode).toBe(0o600);
|
||||
await expect(access(received.file)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("backfills existing template connections without replacing custom text or opt-outs", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(toolConnections).set({ agentInstructions: null, config: { ...f.connection.config, sourceTemplateKey: "mem0" } }).where(eq(toolConnections.id, f.connection.id));
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0298_connection_agent_instructions.sql", import.meta.url), "utf8");
|
||||
await db.$client.unsafe(migration);
|
||||
const populated = await f.service.getConnection(f.connection.id);
|
||||
expect(populated.agentInstructions).toMatchObject({ enabled: true, template: { id: "mem0.usage", version: 1 } });
|
||||
await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: false, text: paragraph } });
|
||||
await db.$client.unsafe(migration);
|
||||
expect((await f.service.getConnection(f.connection.id)).agentInstructions).toEqual({ enabled: false, text: paragraph });
|
||||
});
|
||||
|
||||
it("freezes active turns and removes instructions on the next turn without removing tools", async () => {
|
||||
const f = await fixture();
|
||||
const first = await f.resolve();
|
||||
await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: true, text: "New release procedure." } });
|
||||
expect((await f.resolve())?.digest).not.toBe(first?.digest);
|
||||
expect(first?.text).toContain(paragraph);
|
||||
await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: false, text: paragraph } });
|
||||
expect(await f.resolve()).toBeNull();
|
||||
const session = await f.gateway.createSession(f.binding);
|
||||
expect((await f.gateway.listToolsForSession(session.token)).some(tool => tool.connectionId === f.connection.id)).toBe(true);
|
||||
const context: Record<string, unknown> = { connectionInstructions: first, paperclipWake: { connectionInstructions: first } };
|
||||
const config = await prepareConnectionInstructionDelivery({ resolve: f.resolve, context, config: { paperclipConnectionInstructions: first }, native: false });
|
||||
expect(config.paperclipConnectionInstructions).toBeNull();
|
||||
expect(renderPaperclipWakePrompt(context.paperclipWake)).not.toContain(paragraph);
|
||||
});
|
||||
|
||||
it("follows assignment, action policy, grant revocation, and company boundaries", async () => {
|
||||
const f = await fixture();
|
||||
expect(await f.resolve()).not.toBeNull();
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, f.connection.id));
|
||||
expect(await f.resolve()).toBeNull();
|
||||
await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.connectionId, f.connection.id));
|
||||
const [block] = await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Block handbook", policyType: "block", priority: 1000, selectors: { connectionId: f.connection.id } }).returning();
|
||||
expect(await f.resolve()).toBeNull();
|
||||
await db.delete(toolPolicies).where(eq(toolPolicies.id, block.id));
|
||||
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.targetId, f.agent.id));
|
||||
expect(await f.resolve()).toBeNull();
|
||||
const other = await fixture();
|
||||
await expect(f.gateway.resolveConnectionInstructionsForRun({ ...f.binding, companyId: other.company.id })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(f.gateway.resolveConnectionInstructionsForRun({ ...f.binding, agentId: other.agent.id })).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("invalidates a pending approval when the configured Honcho workspace changes", async () => {
|
||||
const f = await fixture();
|
||||
const config = { ...f.connection.config, sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "before" } };
|
||||
await f.service.updateConnection(f.connection.id, { config });
|
||||
await db.update(toolCatalogEntries).set({ inputSchema: { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id"] } }).where(eq(toolCatalogEntries.id, f.catalogEntry.id));
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connection.id, { enabledCatalogEntryIds: [f.catalogEntry.id], askFirstCatalogEntryIds: [f.catalogEntry.id], access: { agentIds: [f.agent.id] } });
|
||||
let providerCalls = 0;
|
||||
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "instruction-workspace-test", remoteHttpRequest: async () => { providerCalls++; throw new Error("Stale approval must not reach Honcho"); } });
|
||||
const session = await gateway.createSession(f.binding);
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find(tool => tool.connectionId === f.connection.id)!;
|
||||
expect(JSON.stringify(tool.parametersSchema)).not.toContain("workspace_id");
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { query: "release", workspace_id: "forged" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [approval] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.issueId, f.issue.id));
|
||||
await f.service.updateConnection(f.connection.id, { config: { ...config, methodConfig: { workspaceId: "after" } } });
|
||||
await expect(gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: approval.id, actor: { userId: "reviewer" } })).rejects.toMatchObject({ reasonCode: "approved_tool_target_changed" });
|
||||
expect(providerCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("requires the responsible person's active membership and personal identity", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, f.connection.id));
|
||||
await db.update(connectionGrants).set({ kind: "user", subjectUserId: "instruction-owner", isDefault: false }).where(eq(connectionGrants.connectionId, f.connection.id));
|
||||
expect(await f.resolve()).toBeNull();
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: "instruction-owner", membershipRole: "member", status: "active" });
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "instruction-owner" }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
expect(await f.resolve()).not.toBeNull();
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.companyId, f.company.id));
|
||||
expect(await f.resolve()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Honcho workspace configuration", () => {
|
||||
const schema = { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id", "query"] };
|
||||
const connection = { config: { sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "team-workspace" } } } as typeof toolConnections.$inferSelect;
|
||||
it("projects the workspace and hides its argument while keeping old unconfigured tools usable", () => {
|
||||
expect(projectedConnectionToolArguments(connection, { workspace_id: "forged", query: "decisions" }, "query", schema)).toEqual({ workspace_id: "team-workspace", query: "decisions" });
|
||||
expect(projectedConnectionToolInputSchema(connection, schema, "query")).toEqual({ type: "object", properties: { query: { type: "string" } }, required: ["query"] });
|
||||
const old = { ...connection, config: { ...connection.config, methodConfig: {} } };
|
||||
expect(projectedConnectionToolArguments(old, { workspace_id: "chosen" }, "query", schema)).toEqual({ workspace_id: "chosen" });
|
||||
const source = { connection: { ...old, id: "honcho", name: "Honcho", agentInstructions: { enabled: true, text: paragraph } }, grantId: "grant" };
|
||||
expect(composeConnectionInstructions([source])).toBeNull();
|
||||
const configured = composeConnectionInstructions([{ ...source, connection: { ...source.connection, config: connection.config } }]);
|
||||
expect(configured?.text).toContain('"workspaceId":"team-workspace"');
|
||||
const changed = composeConnectionInstructions([{ ...source, connection: { ...source.connection, config: { ...connection.config, methodConfig: { workspaceId: "other" } } } }]);
|
||||
expect(changed?.digest).not.toBe(configured?.digest);
|
||||
const legacy = { ...source, connection: { ...source.connection, config: {}, transportConfig: connection.config } };
|
||||
expect(composeConnectionInstructions([legacy])).toEqual(configured);
|
||||
expect(projectedConnectionToolArguments({ ...connection, config: {}, transportConfig: connection.config }, { workspace_id: "forged" }, "query", schema)).toEqual({ workspace_id: "team-workspace" });
|
||||
expect(composeConnectionInstructions([{ ...legacy, connection: { ...legacy.connection, transportConfig: old.config } }])).toBeNull();
|
||||
const methodChanged = { ...legacy, connection: { ...legacy.connection, config: { connectionMethodKey: "new-method" } } };
|
||||
expect(composeConnectionInstructions([methodChanged])?.digest).not.toBe(configured?.digest);
|
||||
});
|
||||
});
|
||||
@@ -2211,6 +2211,14 @@ function sessionParamsWithConfigMetadata(
|
||||
}
|
||||
|
||||
describe("effective run session config freshness", () => {
|
||||
it("resets legacy sessions after connection instructions change or disappear", async () => {
|
||||
const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: { text: "Use the handbook.", digest: "before" } } });
|
||||
for (const instructions of [null, { text: "Use the updated handbook.", digest: "after" }]) {
|
||||
const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: instructions } });
|
||||
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next }).reset).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses managed AI sessions across temporary credential homes while preserving configuration boundaries", async () => {
|
||||
const config = (home: string) => ({
|
||||
model: "gpt-5.4-mini",
|
||||
|
||||
@@ -2411,6 +2411,57 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists generic instructions through PATCH with configuration permissions and company isolation", async () => {
|
||||
const company = await createCompany(db);
|
||||
const other = await createCompany(db);
|
||||
const { connection } = await createRemoteToolFixture(db, company.id);
|
||||
const settings = { enabled: true, text: "Use the release handbook and cite the checklist." };
|
||||
const app = createRouteApp(db);
|
||||
const endpoint = `/api/tool-connections/${connection.id}`;
|
||||
await request(app).patch(endpoint).send({ agentInstructions: settings }).expect(200);
|
||||
expect((await request(app).get(endpoint).expect(200)).body.agentInstructions).toEqual(settings);
|
||||
await request(app).patch(endpoint).send({ agentInstructions: { ...settings, text: " " } }).expect(400);
|
||||
await request(app).patch(endpoint).send({ agentInstructions: { ...settings, enabled: false } }).expect(200);
|
||||
expect((await request(app).get(endpoint).expect(200)).body.agentInstructions).toEqual({ ...settings, enabled: false });
|
||||
const viewer = `viewer-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, viewer, [], "viewer");
|
||||
await request(createRouteApp(db, boardSessionActor(company.id, "viewer", viewer))).patch(endpoint).send({ agentInstructions: settings }).expect(403);
|
||||
await request(createRouteApp(db, boardSessionActor(other.id, "owner"))).patch(endpoint).send({ agentInstructions: settings }).expect(404);
|
||||
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, connection.id));
|
||||
expect(events.some(event => event.details?.agentInstructionsChanged === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("persists supplied defaults, retains them on catalog refresh, and requires Honcho workspace at setup", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
mockToolsList([{ name: "search", annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: { workspace_id: { type: "string" } } } }]);
|
||||
await expect(service.connectGalleryApp(company.id, { galleryKey: "honcho", credentialValues: { "credentials.authorization": "honcho-fixture-key" } })).rejects.toMatchObject({ status: 400 });
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "honcho", credentialValues: { "credentials.authorization": "honcho-fixture-key" }, configValues: { workspaceId: "fixture-workspace" } });
|
||||
const template = getConnectableAppDefinition("honcho")!.agentInstructions!;
|
||||
expect(connected.connection.agentInstructions).toEqual({ enabled: true, text: template.text, template: { id: template.id, version: template.version } });
|
||||
await service.updateConnection(connected.connectionId, { agentInstructions: { enabled: false, text: "Keep this custom guidance." } });
|
||||
await service.refreshCatalog(connected.connectionId);
|
||||
expect((await service.getConnection(connected.connectionId)).agentInstructions).toEqual({ enabled: false, text: "Keep this custom guidance." });
|
||||
});
|
||||
|
||||
it("preserves custom instructions and opt-outs through OAuth draft recovery and reconnect", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const actor = { actorType: "user" as const, actorId: "board" };
|
||||
const settings = { enabled: false, text: "Look up the current decision before editing it." };
|
||||
const input = { galleryKey: "google-chat", connectionMethodKey: "customer-read-oauth", grantKind: "user" as const, oauthClient: { clientId: "instructions-client", clientSecret: "instructions-secret" }, agentInstructions: settings };
|
||||
const initial = await service.connectGalleryApp(company.id, input, actor);
|
||||
const { agentInstructions: _settings, ...retained } = input;
|
||||
const resumed = await service.connectGalleryApp(company.id, { ...retained, resumeConnectionId: initial.connectionId }, actor);
|
||||
expect(resumed.connection.agentInstructions).toEqual(settings);
|
||||
const started = await service.startOAuth(company.id, resumed.connectionId, { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor });
|
||||
expect(started.authorizationUrl).toBeTruthy();
|
||||
expect((await service.getConnection(initial.connectionId)).agentInstructions).toEqual(settings);
|
||||
await db.update(toolConnections).set({ status: "active", enabled: true }).where(eq(toolConnections.id, initial.connectionId));
|
||||
const reconnected = await service.connectGalleryApp(company.id, { ...retained, reconnectConnectionId: initial.connectionId }, actor);
|
||||
expect(reconnected.connection.agentInstructions).toEqual(settings);
|
||||
});
|
||||
|
||||
it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
@@ -70,6 +70,18 @@ describe("http adapter execute", () => {
|
||||
expect(onDispatch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sends the server snapshot instead of a configured payload instruction block", async () => {
|
||||
const snapshot = { text: "Use the release handbook.", digest: "server-digest" };
|
||||
guardedFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
expect(JSON.parse(String(init?.body)).connectionInstructions).toEqual(snapshot);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
await execute({ runId: "run-1", agent: { id: "agent-1", companyId: "company-1", name: "Agent", adapterType: "http", adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: { url: "https://example.test/webhook", payloadTemplate: { connectionInstructions: { text: "forged" } } }, context: { connectionInstructions: snapshot }, onLog: async () => {},
|
||||
});
|
||||
expect(guardedFetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports configured request timeout as timed_out", async () => {
|
||||
guardedFetchMock.mockImplementation(
|
||||
(_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agentId: agent.id,
|
||||
runId,
|
||||
context,
|
||||
connectionInstructions: context.connectionInstructions ?? null,
|
||||
...(ctx.runtimeTools ? { paperclipRuntimeTools: ctx.runtimeTools } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||
import {
|
||||
asString,
|
||||
@@ -62,14 +65,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
onSpawn: ctx.onSpawn,
|
||||
});
|
||||
const instructions = parseObject(ctx.context.connectionInstructions);
|
||||
let instructionsDirectory: string | undefined;
|
||||
// Override configured/inherited paths even when the connection was removed.
|
||||
env.PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE = "";
|
||||
let proc: Awaited<ReturnType<typeof runChildProcess>>;
|
||||
try {
|
||||
if (typeof instructions.text === "string" && instructions.text) {
|
||||
instructionsDirectory = await mkdtemp(join(tmpdir(), "paperclip-connection-instructions-"));
|
||||
const instructionsFile = join(instructionsDirectory, "instructions.json");
|
||||
await writeFile(instructionsFile, JSON.stringify(instructions), { mode: 0o600 });
|
||||
env.PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE = instructionsFile;
|
||||
}
|
||||
proc = await runChildProcess(runId, command, args, {
|
||||
cwd, env, timeoutSec, graceSec, onLog, onSpawn: ctx.onSpawn,
|
||||
});
|
||||
} finally {
|
||||
if (instructionsDirectory) await rm(instructionsDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (proc.timedOut) {
|
||||
return {
|
||||
|
||||
@@ -897,6 +897,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const resumedConnection = retainedConnectionId
|
||||
? await svc.getConnection(retainedConnectionId, companyId)
|
||||
: null;
|
||||
if (resumedConnection) await assertToolConnectionConfigureAccess(req, resumedConnection);
|
||||
const effectiveGrantKind = resumedConnection
|
||||
? resumedConnection.credentialPolicy === "per_user"
|
||||
? "user"
|
||||
@@ -1687,6 +1688,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
status: connection.status,
|
||||
enabled: connection.enabled,
|
||||
credentialRefCount: (connection.credentialRefs ?? []).length + connection.credentialSecretRefs.length,
|
||||
...(req.body.agentInstructions !== undefined ? { agentInstructionsChanged: true, agentInstructionsEnabled: connection.agentInstructions?.enabled ?? false } : {}),
|
||||
},
|
||||
});
|
||||
res.status(201).json(connection);
|
||||
@@ -2165,6 +2167,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
details: {
|
||||
status: connection.status,
|
||||
enabled: connection.enabled,
|
||||
...(req.body.agentInstructions !== undefined ? { agentInstructionsChanged: true, agentInstructionsEnabled: connection.agentInstructions?.enabled ?? false } : {}),
|
||||
credentialRefCount: (connection.credentialRefs ?? []).length + connection.credentialSecretRefs.length,
|
||||
},
|
||||
});
|
||||
@@ -2177,6 +2180,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
details: {
|
||||
status: connection.status,
|
||||
enabled: connection.enabled,
|
||||
...(req.body.agentInstructions !== undefined ? { agentInstructionsChanged: true, agentInstructionsEnabled: connection.agentInstructions?.enabled ?? false } : {}),
|
||||
lifecycle: change.lifecycle,
|
||||
...change.details,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
connectionAgentInstructionsSchema,
|
||||
connectionInstructionContext,
|
||||
connectionInstructionsConfig,
|
||||
getConnectableAppDefinition,
|
||||
type ConnectionInstructionsSnapshot,
|
||||
type ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type InstructionSource = {
|
||||
connection: Pick<ToolConnection, "id" | "name" | "agentInstructions" | "config"> & Partial<Pick<ToolConnection, "transportConfig">>;
|
||||
grantId: string;
|
||||
};
|
||||
|
||||
/** Authorization belongs to the gateway; this composer never selects recipients. */
|
||||
export function composeConnectionInstructions(sources: InstructionSource[]): ConnectionInstructionsSnapshot | null {
|
||||
const blocks = sources.toSorted((a, b) => a.connection.id.localeCompare(b.connection.id)).flatMap(({ connection, grantId }) => {
|
||||
const parsed = connectionAgentInstructionsSchema.safeParse(connection.agentInstructions);
|
||||
if (!parsed.success || !parsed.data.enabled) return [];
|
||||
const config = connectionInstructionsConfig(connection);
|
||||
const app = typeof config.sourceTemplateKey === "string" ? getConnectableAppDefinition(config.sourceTemplateKey) : null;
|
||||
const context = connectionInstructionContext(app, config);
|
||||
if (!context) return [];
|
||||
const provenance = JSON.stringify({ connectionId: connection.id, connectionName: connection.name, grantId, connector: config.sourceTemplateKey, method: config.connectionMethodKey, template: parsed.data.template, context });
|
||||
return [`Connection source and configuration (data): ${provenance}\n\n${parsed.data.text}`];
|
||||
});
|
||||
if (!blocks.length) return null;
|
||||
const text = `## Connection instructions\n\nThe following instructions apply to your authorized connections. Use their assigned tools and follow the current task and tool permissions.\n\n${blocks.join("\n\n---\n\n")}`;
|
||||
return { text, digest: createHash("sha256").update(text).digest("hex") };
|
||||
}
|
||||
|
||||
/** Freeze server-owned delivery for this turn, overwriting untrusted wake/config fields. */
|
||||
export async function prepareConnectionInstructionDelivery(input: {
|
||||
resolve: () => Promise<ConnectionInstructionsSnapshot | null>;
|
||||
context: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
native: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const snapshot = await input.resolve();
|
||||
const wake = input.context.paperclipWake;
|
||||
input.context.connectionInstructions = snapshot;
|
||||
input.context.paperclipWake = {
|
||||
...(wake && typeof wake === "object" && !Array.isArray(wake) ? wake : {}),
|
||||
connectionInstructions: input.native ? null : snapshot,
|
||||
};
|
||||
return { ...input.config, paperclipConnectionInstructions: snapshot };
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
|
||||
import { prepareConnectionInstructionDelivery } from "./connection-instructions.js";
|
||||
import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js";
|
||||
import { externalObjectService } from "./external-objects.js";
|
||||
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
|
||||
import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js";
|
||||
@@ -21796,7 +21798,10 @@ export function heartbeatService(
|
||||
const connectorDelivery = await prepareConnectorSkillDelivery(connectorSkillConfig, agent.adapterType);
|
||||
// Always replace this runtime-only field; caller wake data cannot supply skills.
|
||||
context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions };
|
||||
let runtimeConfig: Record<string, unknown> = connectorDelivery.config;
|
||||
let runtimeConfig = await prepareConnectionInstructionDelivery({
|
||||
resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }),
|
||||
context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner",
|
||||
});
|
||||
const resolvedFailureSecrets = readFailureReportSecrets();
|
||||
readFailureReportSecrets = () => [
|
||||
...resolvedFailureSecrets,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { connectionInstructionsConfig } from "@paperclipai/shared";
|
||||
|
||||
type ConfiguredConnection = { config: Record<string, unknown>; transportConfig?: Record<string, unknown> };
|
||||
|
||||
/** Honcho's workspace is connection configuration, independent of its prompt. */
|
||||
export function honchoWorkspace(connection: ConfiguredConnection): string | null {
|
||||
const config = connectionInstructionsConfig(connection);
|
||||
if (config.sourceTemplateKey !== "honcho") return null;
|
||||
const settings = config.methodConfig;
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return null;
|
||||
const value = (settings as Record<string, unknown>).workspaceId;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function honchoManagedArguments(
|
||||
connection: ConfiguredConnection,
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const workspace = honchoWorkspace(connection);
|
||||
const properties = schema.properties;
|
||||
return workspace && properties && typeof properties === "object" && "workspace_id" in properties
|
||||
? { workspace_id: workspace }
|
||||
: undefined;
|
||||
}
|
||||
@@ -64,6 +64,12 @@ export function getAssignedMcpGateway(db: Db): ToolGatewayService {
|
||||
return gateway;
|
||||
}
|
||||
|
||||
/** No configured gateway means no authorized connection tools or instructions. */
|
||||
export async function resolveAssignedConnectionInstructionsForRun(db: Db, binding: { companyId: string; agentId: string; runId: string }) {
|
||||
const gateway = assignedMcpGateways.get(db);
|
||||
return gateway ? gateway.resolveConnectionInstructionsForRun(binding) : null;
|
||||
}
|
||||
|
||||
function assignedToolName(name: string): string {
|
||||
const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
const separator = name.lastIndexOf(":");
|
||||
|
||||
@@ -421,6 +421,20 @@ describe("capability-gated connection tool refresh", () => {
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, refreshTools: true, toolRefreshOnResume: false })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["add", "edit", "remove"])("replaces sessions when connection instructions %s, even with live tool refresh", (change) => {
|
||||
const block = (text: string) => ({ text, digest: createHash("sha256").update(text).digest("hex") });
|
||||
const previous = execution(previousRunId);
|
||||
const current = execution(currentRunId);
|
||||
if (change !== "add") previous.runtimeContext.connectionInstructions = block("Prior connection instructions.");
|
||||
if (change !== "remove") current.runtimeContext.connectionInstructions = block("Current connection instructions.");
|
||||
previous.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(previous.runtimeContext);
|
||||
current.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(current.runtimeContext);
|
||||
const prior = previousRun({ nativeExecutionInput: previous });
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: prior, currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull();
|
||||
current.runtimeContext = previous.runtimeContext;
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: prior, currentExecution: current })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves instruction and identity fences even during a supported refresh", () => {
|
||||
const current = execution(currentRunId);
|
||||
current.runtimeContext.instructions.bundle.digest = "b".repeat(64);
|
||||
|
||||
@@ -252,6 +252,9 @@ export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeA
|
||||
instructions: { ...instructions, ...(input.instructionWorkingCopy ? { workingCopy: input.instructionWorkingCopy } : {}) },
|
||||
skills,
|
||||
mcp,
|
||||
...(input.runtimeConfig.paperclipConnectionInstructions ? {
|
||||
connectionInstructions: input.runtimeConfig.paperclipConnectionInstructions as { text: string; digest: string },
|
||||
} : {}),
|
||||
};
|
||||
return parseNativeRuntimeContext({ ...snapshot, aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot) });
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { AggregatorDiscoveryUnavailableError, discoverArcadeApps, discoverExecut
|
||||
import { COMPOSIO_APP_TOOLKITS, findComposioCatalogApp } from "@paperclipai/shared/aggregator-app-catalog";
|
||||
import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput, ComposioAppSetupResult, ComposioAppSnapshot, ComposioAppsResponse } from "@paperclipai/shared";
|
||||
import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js";
|
||||
import { honchoManagedArguments } from "./honcho-connection.js";
|
||||
import { defaultConnectionAgentInstructions } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
||||
import { BROWSER_USE_TOOLS } from "@paperclipai/shared";
|
||||
@@ -1208,10 +1210,14 @@ export function projectedConnectionHeaders(
|
||||
const headers: Record<string, string> = {};
|
||||
if (app) {
|
||||
const method = connectionMethodForConnection(app, connection);
|
||||
Object.assign(headers, normalizeConnectionMethodConfig(
|
||||
method,
|
||||
asRecord(connection.config.methodConfig),
|
||||
).headers);
|
||||
// Non-transport settings do not participate in header projection. In
|
||||
// particular, legacy connections can lack a newly introduced setting.
|
||||
if ([...(method.tenantFields ?? []), ...(method.extensionFields ?? [])].some((field) => field.transport?.location === "header")) {
|
||||
Object.assign(headers, normalizeConnectionMethodConfig(
|
||||
method,
|
||||
asRecord(connection.config.methodConfig),
|
||||
).headers);
|
||||
}
|
||||
}
|
||||
if (
|
||||
connection.transport === "mcp_remote" &&
|
||||
@@ -1304,7 +1310,10 @@ export function projectedConnectionToolArguments(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
parameters: unknown,
|
||||
toolName: string,
|
||||
inputSchema: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const managed = honchoManagedArguments(connection, inputSchema);
|
||||
if (managed) parameters = mergeManagedToolArguments(asRecord(parameters), managed);
|
||||
assertGoogleChatToolArgumentsSupported(connection, toolName, parameters);
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string"
|
||||
@@ -1325,6 +1334,8 @@ export function projectedConnectionToolInputSchema(
|
||||
inputSchema: Record<string, unknown>,
|
||||
toolName: string,
|
||||
): Record<string, unknown> {
|
||||
const managed = honchoManagedArguments(connection, inputSchema);
|
||||
if (managed) inputSchema = stripManagedToolArgumentSchema(inputSchema, managed);
|
||||
inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema);
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string"
|
||||
@@ -1623,6 +1634,7 @@ function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection
|
||||
credentialPolicy: row.credentialPolicy,
|
||||
status: row.status,
|
||||
enabled: row.enabled && !retired,
|
||||
agentInstructions: row.agentInstructions ?? null,
|
||||
config: row.config ?? {},
|
||||
transportConfig: row.transportConfig ?? {},
|
||||
credentialRefs: row.credentialRefs ?? [],
|
||||
@@ -2245,6 +2257,7 @@ function connectionSetupMutationFingerprint(
|
||||
transport: row.transport,
|
||||
status: row.status,
|
||||
enabled: row.enabled,
|
||||
agentInstructions: row.agentInstructions,
|
||||
config: row.config,
|
||||
transportConfig: row.transportConfig,
|
||||
credentialRefs: row.credentialRefs,
|
||||
@@ -12564,7 +12577,7 @@ export function toolAccessService(
|
||||
const normalizedMethodConfig =
|
||||
isGoogleSheetsRobotMethod || !method
|
||||
? null
|
||||
: normalizeConnectionMethodConfig(method, input.configValues);
|
||||
: normalizeConnectionMethodConfig(method, input.configValues ?? asRecord(asRecord(retainedConnection?.config).methodConfig));
|
||||
const remoteUrlCredential =
|
||||
transport === "mcp_remote" && input.link
|
||||
? splitRemoteUrlCredential(input.link)
|
||||
@@ -12962,6 +12975,10 @@ export function toolAccessService(
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
agentInstructions: input.agentInstructions !== undefined
|
||||
? input.agentInstructions
|
||||
: retainedConnection ? retainedConnection.agentInstructions
|
||||
: defaultConnectionAgentInstructions(galleryEntry?.agentInstructions),
|
||||
config,
|
||||
transportConfig: config,
|
||||
credentialRefs,
|
||||
@@ -12995,6 +13012,10 @@ export function toolAccessService(
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
agentInstructions: input.agentInstructions !== undefined
|
||||
? input.agentInstructions
|
||||
: retainedConnection ? retainedConnection.agentInstructions
|
||||
: defaultConnectionAgentInstructions(galleryEntry?.agentInstructions),
|
||||
config,
|
||||
transportConfig: config,
|
||||
credentialRefs,
|
||||
@@ -13391,6 +13412,7 @@ export function toolAccessService(
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
agentInstructions: revivedConnectionPrevious.agentInstructions,
|
||||
name: revivedConnectionPrevious.name,
|
||||
transport: revivedConnectionPrevious.transport,
|
||||
status: revivedConnectionPrevious.status,
|
||||
@@ -13700,7 +13722,7 @@ export function toolAccessService(
|
||||
const config = { ...connection.config };
|
||||
delete config.mcpPreserveAccess;
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, updatedAt: new Date() })
|
||||
const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
||||
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning();
|
||||
await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)));
|
||||
return row;
|
||||
@@ -14053,7 +14075,7 @@ export function toolAccessService(
|
||||
);
|
||||
const [updatedConnection] = await tx
|
||||
.update(toolConnections)
|
||||
.set({ status: "active", enabled: true, updatedAt: new Date() })
|
||||
.set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
||||
.where(eq(toolConnections.id, connection.id))
|
||||
.returning();
|
||||
await tx
|
||||
@@ -17851,6 +17873,9 @@ export function toolAccessService(
|
||||
(input.authKind === "oauth" ? "per_user" : "shared"),
|
||||
status: input.status ?? "draft",
|
||||
enabled: input.enabled ?? false,
|
||||
agentInstructions: input.agentInstructions !== undefined ? input.agentInstructions : defaultConnectionAgentInstructions(
|
||||
typeof config.sourceTemplateKey === "string" ? getConnectableAppDefinition(config.sourceTemplateKey)?.agentInstructions : null,
|
||||
),
|
||||
config,
|
||||
transportConfig: isGoogleSheetsConnectionConfig(config)
|
||||
? config
|
||||
@@ -18723,6 +18748,10 @@ export function toolAccessService(
|
||||
const config = normalizeGoogleSheetsConnectionConfig(
|
||||
input.config ?? input.transportConfig ?? existing.config,
|
||||
);
|
||||
if (input.config?.methodConfig !== undefined && typeof config.sourceTemplateKey === "string") {
|
||||
const app = getConnectableAppDefinition(config.sourceTemplateKey);
|
||||
if (app) normalizeConnectionMethodConfig(connectionMethodForConnection(app, { ...existing, config }), asRecord(config.methodConfig));
|
||||
}
|
||||
if (existing.transport === "mcp_remote")
|
||||
await assertRemoteConnectionEndpointsAllowed(config);
|
||||
if (existing.transport === "local_stdio")
|
||||
@@ -18745,6 +18774,7 @@ export function toolAccessService(
|
||||
name: input.name ?? existing.name,
|
||||
status: input.status ?? existing.status,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}),
|
||||
config,
|
||||
transportConfig: isGoogleSheetsConnectionConfig(config)
|
||||
? config
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { composeConnectionInstructions } from "./connection-instructions.js";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { boundedMcpToolName } from "./mcp-tool-names.js";
|
||||
import { browserUseService } from "./browser-use.js";
|
||||
@@ -2985,6 +2986,18 @@ export function createToolGatewayService(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared connection descriptors and task restrictions; no provider calls. */
|
||||
async function connectionToolsForContext(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
|
||||
const guestBotConnection = await githubGuestBotConnectionForSession(db, session);
|
||||
const connectedTools = (await connectedMcpToolsForCompany(session.companyId))
|
||||
.filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat"));
|
||||
return [
|
||||
...await githubBotToolsForSession(db, session),
|
||||
...await slackToolsForSession(db, session),
|
||||
...connectedTools,
|
||||
];
|
||||
}
|
||||
|
||||
async function buildToolsForContext(
|
||||
session: ToolGatewaySession,
|
||||
signal?: AbortSignal,
|
||||
@@ -2993,16 +3006,11 @@ export function createToolGatewayService(
|
||||
if (session.agentId) {
|
||||
await assertAgentInCompany(session.companyId, session.agentId);
|
||||
}
|
||||
const guestBotConnection = await githubGuestBotConnectionForSession(db, session);
|
||||
const allConnectedTools = (await connectedMcpToolsForCompany(
|
||||
session.companyId,
|
||||
)).filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat"));
|
||||
const onDemandTargets = allConnectedTools.filter(isOnDemandRemoteTool);
|
||||
const connectionTools = await connectionToolsForContext(session);
|
||||
const onDemandTargets = connectionTools.filter(isOnDemandRemoteTool);
|
||||
const tools = [
|
||||
...allTools(),
|
||||
...await githubBotToolsForSession(db, session),
|
||||
...await slackToolsForSession(db, session),
|
||||
...allConnectedTools.filter((tool) => !isOnDemandRemoteTool(tool)),
|
||||
...connectionTools.filter((tool) => !isOnDemandRemoteTool(tool)),
|
||||
].filter(
|
||||
(tool) =>
|
||||
session.agentId ||
|
||||
@@ -4802,7 +4810,7 @@ export function createToolGatewayService(
|
||||
): Promise<unknown> {
|
||||
if (tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") return parameters;
|
||||
const { connection, entry } = await resolveConnectedRemoteTool(session, tool);
|
||||
return projectedConnectionToolArguments(connection, parameters, entry.toolName);
|
||||
return projectedConnectionToolArguments(connection, parameters, entry.toolName, entry.inputSchema ?? {});
|
||||
}
|
||||
|
||||
async function approvedManagedArgumentsRemainCurrent(
|
||||
@@ -9020,6 +9028,44 @@ export function createToolGatewayService(
|
||||
} catch { return false; }
|
||||
},
|
||||
|
||||
/** Standing instructions use exactly the same authorization as tool discovery. */
|
||||
async resolveConnectionInstructionsForRun(binding: { companyId: string; agentId: string; runId: string }) {
|
||||
const candidates = await db.select().from(toolConnections).where(and(
|
||||
eq(toolConnections.companyId, binding.companyId), eq(toolConnections.enabled, true),
|
||||
eq(toolConnections.status, "active"), sql`${toolConnections.agentInstructions}->>'enabled' = 'true'`,
|
||||
));
|
||||
if (!candidates.length) return null;
|
||||
const runContext = await resolveRunContext(binding);
|
||||
const session = await captureSessionIdentity({
|
||||
...binding, ...runContext, id: randomUUID(), token: "", createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
await assertAgentInCompany(binding.companyId, binding.agentId);
|
||||
const candidateIds = new Set(candidates.map((connection) => connection.id));
|
||||
// Prompt assembly needs authorization, not a full discovery listing. Share
|
||||
// descriptors and policy decisions, but only authorize candidate sources;
|
||||
// discovery admission pressure must not remove guidance or reset sessions.
|
||||
const tools = (await connectionToolsForContext(session))
|
||||
.filter((tool) => tool.connectionId && candidateIds.has(tool.connectionId));
|
||||
const decisions = await decideToolsForListing(tools, (tool) => policyInputForTool({ session, tool }));
|
||||
const visible = new Set(decisions.flatMap(({ tool, decision }) =>
|
||||
(decision.allowed || decision.decision === "require_approval") && tool.connectionId ? [tool.connectionId] : [],
|
||||
));
|
||||
const sources: Parameters<typeof composeConnectionInstructions>[0] = [];
|
||||
for (const connection of candidates) {
|
||||
if (!visible.has(connection.id)) continue;
|
||||
try {
|
||||
const grant = await resolveConnectionGrant(session, connection, false);
|
||||
sources.push({ connection, grantId: grant.id });
|
||||
} catch (error) {
|
||||
// A missing/revoked identity makes this connection unavailable, without
|
||||
// opening an interaction or blocking unrelated work at prompt assembly.
|
||||
if (!(error instanceof ToolGatewayHttpError) && !(error instanceof HttpError)) throw error;
|
||||
if (error.status >= 500) throw error;
|
||||
}
|
||||
}
|
||||
return composeConnectionInstructions(sources);
|
||||
},
|
||||
|
||||
async browserUseResources(binding: { companyId: string; agentId: string; runId?: string; issueId?: string }) {
|
||||
if (!binding.runId || !binding.issueId) return [];
|
||||
const session: ToolGatewaySession = { ...binding, runId: binding.runId, issueId: binding.issueId, id: randomUUID(), token: "", projectId: null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) };
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface CreateToolConnectionInput {
|
||||
}
|
||||
|
||||
export interface UpdateToolConnectionInput {
|
||||
agentInstructions?: import("@paperclipai/shared").ConnectionAgentInstructions | null;
|
||||
name?: string;
|
||||
status?: ToolConnection["status"];
|
||||
config?: Record<string, unknown>;
|
||||
@@ -322,6 +323,7 @@ export const toolsApi = {
|
||||
enabledCatalogEntryIds: string[];
|
||||
askFirstCatalogEntryIds: string[];
|
||||
reviewedCatalogEntryIds?: string[];
|
||||
agentInstructions?: import("@paperclipai/shared").ConnectionAgentInstructions | null;
|
||||
access: "all_agents" | { agentIds: string[] };
|
||||
}) =>
|
||||
api.post<FinishToolAppResult>(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, installConnectionGuidanceFixtures } from "../../../storybook/fixtures/connectionInstructions";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("keeps the directory and unimplemented connector requests inside the preview", async () => {
|
||||
const fallback = vi.fn().mockRejectedValue(new Error("Unexpected network request"));
|
||||
vi.stubGlobal("fetch", fallback);
|
||||
const client = new QueryClient();
|
||||
const cleanup = installConnectionGuidanceFixtures(client, "Honcho", { setup: false, askFirst: false });
|
||||
try {
|
||||
const directory = await window.fetch(`/api/companies/${GUIDANCE_COMPANY}/user-directory`);
|
||||
expect((await directory.json()).users[0].user.name).toBe("Riley Board");
|
||||
const unknownOperation = await window.fetch(`/api/companies/${GUIDANCE_COMPANY}/tools/unsupported-operation`, { method: "POST" });
|
||||
expect(unknownOperation.status).toBe(422);
|
||||
const providerTest = await window.fetch(`/api/tool-connections/${GUIDANCE_CONNECTION}/test-action`, { method: "POST" });
|
||||
expect(providerTest.status).toBe(422);
|
||||
const oauth = await window.fetch("/api/tools/oauth/start", { method: "POST" });
|
||||
expect(oauth.status).toBe(422);
|
||||
expect(fallback).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
cleanup();
|
||||
client.clear();
|
||||
}
|
||||
expect(window.fetch).toBe(fallback);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CONNECTION_INSTRUCTIONS_MAX_LENGTH,
|
||||
connectionInstructionContext,
|
||||
connectionInstructionsConfig,
|
||||
defaultConnectionAgentInstructions,
|
||||
getConnectableAppDefinition,
|
||||
type ConnectionAgentInstructions,
|
||||
type ConnectionInstructionTemplate,
|
||||
type ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
|
||||
export function ConnectionInstructionsEditor({ provider, template, value, onChange, disabled = false }: {
|
||||
provider: string;
|
||||
template: ConnectionInstructionTemplate;
|
||||
value: ConnectionAgentInstructions;
|
||||
onChange: (value: ConnectionAgentInstructions) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const changed = value.text !== template.text || value.template?.id !== template.id || value.template?.version !== template.version;
|
||||
return <section className="space-y-3" aria-label="Agent instructions">
|
||||
<h3 className="text-sm font-medium">Agent instructions</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label htmlFor={id} className="text-sm">Tell agents to use {provider}</label>
|
||||
<ToggleSwitch id={id} checked={value.enabled} disabled={disabled} onCheckedChange={(enabled) => onChange({ ...value, enabled })} />
|
||||
</div>
|
||||
{!value.enabled && <InlineBanner tone="warning">Agents can still use this connection’s tools, but won’t receive these instructions.</InlineBanner>}
|
||||
{editing ? <Textarea aria-label="Instructions text" value={value.text} disabled={disabled} maxLength={CONNECTION_INSTRUCTIONS_MAX_LENGTH}
|
||||
className="min-h-40" onChange={(event) => onChange({ ...value, text: event.target.value })} />
|
||||
: <p className="whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground">{value.text}</p>}
|
||||
{!value.text.trim() && <p role="alert" className="text-sm text-destructive">Enter instructions.</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" disabled={disabled} onClick={() => setEditing(!editing)}>{editing ? "Done editing" : "Edit instructions"}</Button>
|
||||
{changed && <Button variant="ghost" size="sm" disabled={disabled} onClick={() => onChange({ ...defaultConnectionAgentInstructions(template)!, enabled: value.enabled })}>Reset to default</Button>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ConnectionInstructionsSettings({ connection, provider, template, canConfigure }: {
|
||||
connection: ToolConnection;
|
||||
provider: string;
|
||||
template: ConnectionInstructionTemplate;
|
||||
canConfigure: boolean;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const [draft, setDraft] = useState<ConnectionAgentInstructions | null>(null);
|
||||
// A deliberately cleared setting stays off. Defaults are persisted by the server.
|
||||
const saved = connection.agentInstructions ?? { ...defaultConnectionAgentInstructions(template)!, enabled: false };
|
||||
const value = draft ?? saved;
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => toolsApi.updateConnection(connection.id, { agentInstructions: value }),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: ["tools"] });
|
||||
setDraft(null);
|
||||
},
|
||||
});
|
||||
const dirty = JSON.stringify(value) !== JSON.stringify(saved);
|
||||
return <div className="space-y-4">
|
||||
<ConnectionInstructionsEditor provider={provider} template={template} value={value} onChange={setDraft} disabled={!canConfigure || mutation.isPending} />
|
||||
{mutation.isError && <div role="alert"><InlineBanner tone="danger">{mutation.error instanceof Error ? mutation.error.message : "Couldn’t save instructions. Try again."}</InlineBanner></div>}
|
||||
{dirty && <div className="flex items-center justify-between">
|
||||
<Button variant="ghost" disabled={mutation.isPending} onClick={() => { setDraft(null); mutation.reset(); }}>Cancel</Button>
|
||||
<Button disabled={!canConfigure || mutation.isPending || !value.text.trim()} onClick={() => mutation.mutate()}>{mutation.isPending ? "Saving…" : "Save instructions"}</Button>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AgentConnectionInstructions({ companyId, agentId }: { companyId: string; agentId: string }) {
|
||||
const query = useQuery({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agentId), queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agentId) });
|
||||
const sources = query.data?.installedConnections.filter((connection) => connection.agentInstructions && connection.enabled && connection.status === "active") ?? [];
|
||||
if (query.isLoading) return <p className="text-sm text-muted-foreground">Loading connection instructions…</p>;
|
||||
if (query.isError) return <InlineBanner tone="danger" actions={<Button variant="ghost" onClick={() => void query.refetch()}>Retry</Button>}>Couldn’t load connection instructions.</InlineBanner>;
|
||||
if (!sources.length) return null;
|
||||
return <section className="mt-8 space-y-4" aria-label="From connections">
|
||||
<h3 className="text-sm font-medium">From connections</h3>
|
||||
<p className="text-sm text-muted-foreground">Enabled instructions are included when the connection is available for the task. Access can depend on the task’s responsible person and action permissions.</p>
|
||||
{sources.map((connection) => {
|
||||
const config = connectionInstructionsConfig(connection);
|
||||
const app = getConnectableAppDefinition(String(config.sourceTemplateKey ?? ""));
|
||||
const context = connectionInstructionContext(app, config);
|
||||
return <div key={connection.id} className="space-y-3 rounded-lg border border-border p-4">
|
||||
<Link className="text-sm font-medium underline underline-offset-2" to={`/apps/${connection.id}/permissions`}>{connection.name}</Link>
|
||||
{!connection.agentInstructions!.enabled && <InlineBanner tone="warning">These instructions are turned off for this connection.</InlineBanner>}
|
||||
{context === null ? <InlineBanner tone="warning">Complete this connection’s configuration before agents receive its instructions.</InlineBanner>
|
||||
: <p className="whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground">{connection.agentInstructions!.text}</p>}
|
||||
</div>;
|
||||
})}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ConnectionInstructionsEditor } from "./ConnectionInstructions";
|
||||
import { connectionInstructionsConfig, defaultConnectionAgentInstructions, type ConnectionAgentInstructions } from "@paperclipai/shared";
|
||||
import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup";
|
||||
import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog";
|
||||
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
|
||||
@@ -503,6 +505,11 @@ export function readConnectionIntentOAuthOutcome(
|
||||
}
|
||||
|
||||
export interface ConnectionSetupFlowProps {
|
||||
/** Optional settings composed into the standard credential form above its footer. */
|
||||
additionalSettings?: ReactNode;
|
||||
/** Provider prerequisites shown before credentials in the standard setup flow. */
|
||||
connectionSettings?: ReactNode;
|
||||
additionalSettingsValid?: boolean;
|
||||
upstreamServiceName?: string;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
|
||||
/** Provider-specific authentication inside the existing access/setup shell. Undefined retains the standard credential form. */
|
||||
@@ -562,6 +569,9 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) {
|
||||
}
|
||||
|
||||
function StandardConnectionSetupFlow({
|
||||
additionalSettings,
|
||||
connectionSettings,
|
||||
additionalSettingsValid = true,
|
||||
byoOnly = false,
|
||||
credentialSource = "paperclip_vault",
|
||||
host = "page",
|
||||
@@ -671,6 +681,7 @@ function StandardConnectionSetupFlow({
|
||||
const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState("");
|
||||
const [vercelConnector, setVercelConnector] = useState("");
|
||||
const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection && aiConnection.mode !== "responsible_user" ? `ai-${aiConnection.method}` : "");
|
||||
const [instructionDraft, setInstructionDraft] = useState<{ slug: string; value: ConnectionAgentInstructions } | null>(null);
|
||||
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
|
||||
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
|
||||
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(null);
|
||||
@@ -1209,17 +1220,32 @@ function StandardConnectionSetupFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const instructionConnection = resumeConnection ?? reconnectConnection ?? resumableOAuthConnection;
|
||||
const instructionTemplate = entry?.agentInstructions;
|
||||
const instructionValue = entry && instructionTemplate
|
||||
? instructionDraft?.slug === entry.slug ? instructionDraft.value
|
||||
: instructionConnection ? instructionConnection.agentInstructions ?? { ...defaultConnectionAgentInstructions(instructionTemplate)!, enabled: false }
|
||||
: defaultConnectionAgentInstructions(instructionTemplate)
|
||||
: null;
|
||||
const instructionsValid = !instructionValue || Boolean(instructionValue.text.trim());
|
||||
|
||||
const oauthStartMutation = useMutation({
|
||||
// Retry/reconnect reads identity from the durable connection. Provider is
|
||||
// not identity: an organization Notion connection must stay organization-
|
||||
// scoped, while a personal one must put its token back on that user grant.
|
||||
mutationFn: (connection: ToolConnection) => toolsApi.startOAuth(connection.id, {
|
||||
mutationFn: async (connection: ToolConnection) => {
|
||||
// Persist before navigation, including retries that reuse an existing OAuth draft.
|
||||
if (instructionValue && JSON.stringify(instructionValue) !== JSON.stringify(connection.agentInstructions)) {
|
||||
await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue });
|
||||
}
|
||||
return toolsApi.startOAuth(connection.id, {
|
||||
asCurrentUser: connection.credentialPolicy === "per_user",
|
||||
...(connection.credentialPolicy === "per_agent"
|
||||
? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] }
|
||||
: {}),
|
||||
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: (start) => void prepareAndOpenOAuth(start),
|
||||
onError: (error) => {
|
||||
const details = error instanceof ApiError && error.body && typeof error.body === "object"
|
||||
@@ -1281,6 +1307,7 @@ function StandardConnectionSetupFlow({
|
||||
);
|
||||
result = await toolsApi.connectApp(selectedCompanyId!, {
|
||||
galleryKey: connectEntry.slug,
|
||||
...(instructionValue ? { agentInstructions: instructionValue } : {}),
|
||||
...(connectionMethodKey ? { connectionMethodKey } : {}),
|
||||
name: connectionName,
|
||||
credentialSource,
|
||||
@@ -1621,16 +1648,12 @@ function StandardConnectionSetupFlow({
|
||||
|
||||
useEffect(() => {
|
||||
const savedConnection = resumeConnection ?? reconnectConnection;
|
||||
const savedOAuth = savedConnection?.config?.oauth as Record<string, unknown> | undefined;
|
||||
if (
|
||||
!savedConnection
|
||||
|| !entry
|
||||
|| (savedConnection.status !== "draft" && savedOAuth?.clientRegistrationSource !== "manual")
|
||||
|| hydratedResumeConnectionIdRef.current === savedConnection.id
|
||||
) return;
|
||||
const storedConfig = savedConnection.config && typeof savedConnection.config === "object"
|
||||
? savedConnection.config
|
||||
: {};
|
||||
const storedConfig = connectionInstructionsConfig(savedConnection);
|
||||
const storedSource = typeof storedConfig.sourceTemplateKey === "string"
|
||||
? storedConfig.sourceTemplateKey
|
||||
: null;
|
||||
@@ -1716,6 +1739,7 @@ function StandardConnectionSetupFlow({
|
||||
const finished = await toolsApi.finishApp(selectedCompanyId!, connected.connectionId, {
|
||||
enabledCatalogEntryIds: enabledIds,
|
||||
askFirstCatalogEntryIds: askFirstIds,
|
||||
...(instructionValue ? { agentInstructions: instructionValue } : {}),
|
||||
access: selection,
|
||||
...(requestedAgentId ? { preserveExistingAccess: true } : {}),
|
||||
});
|
||||
@@ -1938,6 +1962,7 @@ function StandardConnectionSetupFlow({
|
||||
// same controls the deleted Access step owned, inline and never blocking.
|
||||
const renderConnectionDefaults = step === "key" ? (
|
||||
(extra?: ReactNode, forceOpen?: boolean) => (
|
||||
<>
|
||||
<ConnectionAccessDefaults
|
||||
key="connection-defaults"
|
||||
extra={extra}
|
||||
@@ -1977,6 +2002,9 @@ function StandardConnectionSetupFlow({
|
||||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||||
disabled={connectMutation.isPending || oauthStartMutation.isPending}
|
||||
/>
|
||||
{entry && instructionTemplate && instructionValue && <div className="mt-6"><ConnectionInstructionsEditor provider={entry.name} template={instructionTemplate} value={instructionValue} onChange={(value) => setInstructionDraft({ slug: entry.slug, value })} disabled={connectMutation.isPending || oauthStartMutation.isPending} /></div>}
|
||||
{additionalSettings && <div className="mt-6">{additionalSettings}</div>}
|
||||
</>
|
||||
)
|
||||
) : null;
|
||||
// A provider can advertise registration and still refuse this deployment's
|
||||
@@ -2056,6 +2084,7 @@ function StandardConnectionSetupFlow({
|
||||
</div>
|
||||
) : null}
|
||||
defaults={curatedOAuthDefaults}
|
||||
settingsValid={additionalSettingsValid && instructionsValid}
|
||||
onOpenAuthorization={openAuthorizationTab}
|
||||
onRetry={async () => {
|
||||
const firstAttempt = !directOAuthAccessConfirmedRef.current;
|
||||
@@ -2249,7 +2278,7 @@ function StandardConnectionSetupFlow({
|
||||
? "Choose a reviewed app to connect through Vercel."
|
||||
: "Pick the app you want your agents to use."
|
||||
: stepLabels.length <= 1
|
||||
? "Connect now — permissions and access are yours to change afterwards."
|
||||
? undefined
|
||||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||||
}
|
||||
step={step}
|
||||
@@ -2268,6 +2297,8 @@ function StandardConnectionSetupFlow({
|
||||
)
|
||||
)}
|
||||
|
||||
{step === "key" && connectionSettings && <div className="mx-auto mb-6 max-w-xl">{connectionSettings}</div>}
|
||||
|
||||
{step === "gallery" && (
|
||||
<GalleryStep
|
||||
loading={galleryQuery.isLoading}
|
||||
@@ -2381,6 +2412,7 @@ function StandardConnectionSetupFlow({
|
||||
</div>
|
||||
) : step === "key" && entry && credentialStep !== undefined ? credentialStep : step === "key" && entry ? (
|
||||
<KeyStep
|
||||
settingsValid={additionalSettingsValid && instructionsValid}
|
||||
entry={entry}
|
||||
error={connectMutation.isError ? (connectMutation.error instanceof Error ? connectMutation.error.message : "Please check your key and try again.") : null}
|
||||
values={credentials}
|
||||
@@ -2574,7 +2606,7 @@ export function StepHeader({
|
||||
}: {
|
||||
title?: string;
|
||||
headingRef?: Ref<HTMLHeadingElement>;
|
||||
subtitle: string;
|
||||
subtitle?: string;
|
||||
step: Step;
|
||||
activeIndex: number;
|
||||
labels: string[];
|
||||
@@ -2598,7 +2630,7 @@ export function StepHeader({
|
||||
<h1 ref={headingRef} tabIndex={headingRef ? -1 : undefined} className="text-2xl font-bold tracking-tight outline-none">
|
||||
{title ?? (appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2644,6 +2676,7 @@ export function OAuthConnectStateScreen({
|
||||
guidance,
|
||||
defaults,
|
||||
onRetry,
|
||||
settingsValid = true,
|
||||
onOpenAuthorization,
|
||||
onBack,
|
||||
onCancel,
|
||||
@@ -2675,6 +2708,7 @@ export function OAuthConnectStateScreen({
|
||||
guidance?: ReactNode;
|
||||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||||
defaults?: ReactNode;
|
||||
settingsValid?: boolean;
|
||||
onOpenAuthorization?: () => void;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
@@ -2762,7 +2796,7 @@ export function OAuthConnectStateScreen({
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{phase === "error" || phase === "entry" ? (
|
||||
<Button type="button" onClick={onRetry}>
|
||||
<Button type="button" onClick={onRetry} disabled={!settingsValid}>
|
||||
{phase === "entry"
|
||||
? resuming ? `Finish with ${serverName}` : `Continue to ${serverName}`
|
||||
: "Try again"}
|
||||
@@ -3419,6 +3453,7 @@ function SegmentedOption({
|
||||
}
|
||||
|
||||
function KeyStep({
|
||||
settingsValid = true,
|
||||
entry,
|
||||
error,
|
||||
values,
|
||||
@@ -3444,6 +3479,7 @@ function KeyStep({
|
||||
onBack,
|
||||
onConnect,
|
||||
}: {
|
||||
settingsValid?: boolean;
|
||||
entry: AppDefinition;
|
||||
error?: string | null;
|
||||
values: Record<string, string>;
|
||||
@@ -3674,7 +3710,7 @@ function KeyStep({
|
||||
|
||||
if (isGoogleSheetsRobotMethod(entry, method)) {
|
||||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||||
const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
|
||||
const canConnect = settingsValid && !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="space-y-6">
|
||||
@@ -3840,9 +3876,9 @@ function KeyStep({
|
||||
placeholder={field.type === "text" && field.secret === false ? field.placeholder : "••••••••••••••••"}
|
||||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{field.helperMd ?? "Create a key with read and write permissions for the resources your agents need. Paperclip cannot add permissions to an existing key."}
|
||||
</p>
|
||||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">
|
||||
{field.helperMd}
|
||||
</p>}
|
||||
{field.helpUrl && (
|
||||
<a
|
||||
href={field.helpUrl}
|
||||
@@ -3866,7 +3902,7 @@ function KeyStep({
|
||||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onConnect} disabled={submitting || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
|
||||
<Button onClick={onConnect} disabled={submitting || !settingsValid || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting
|
||||
? "Checking…"
|
||||
@@ -4041,9 +4077,12 @@ function MethodConfigField({
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
aria-label={field.label}
|
||||
maxLength={field.validation?.maxLength}
|
||||
className="mt-2 h-11"
|
||||
/>
|
||||
)}
|
||||
{field.required && (typeof value !== "string" || !value.trim()) && <p className="mt-2 text-xs text-muted-foreground">{field.label} is required.</p>}
|
||||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">{field.helperMd}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { connectionInstructionsConfig, type ToolConnection } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
|
||||
export function HonchoWorkspaceSettings({ connection, canConfigure }: { connection: ToolConnection; canConfigure: boolean }) {
|
||||
const id = useId();
|
||||
const client = useQueryClient();
|
||||
const connectionConfig = connectionInstructionsConfig(connection);
|
||||
const config = (connectionConfig.methodConfig ?? {}) as Record<string, unknown>;
|
||||
const saved = typeof config.workspaceId === "string" ? config.workspaceId : "";
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const value = draft ?? saved;
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => toolsApi.updateConnection(connection.id, { config: { ...connectionConfig, methodConfig: { ...config, workspaceId: value.trim() } } }),
|
||||
onSuccess: async () => { await client.invalidateQueries({ queryKey: ["tools"] }); setDraft(null); },
|
||||
});
|
||||
return <section className="space-y-3" aria-label="Honcho workspace">
|
||||
<label htmlFor={id} className="text-sm font-medium">Honcho workspace</label>
|
||||
<Input id={id} value={value} maxLength={512} disabled={!canConfigure || mutation.isPending} placeholder="Workspace ID" onChange={(event) => setDraft(event.target.value)} />
|
||||
{!value.trim() && <InlineBanner tone="warning">Enter a workspace to include Honcho instructions. Existing tools remain available.</InlineBanner>}
|
||||
{mutation.isError && <div role="alert"><InlineBanner tone="danger">{mutation.error.message}</InlineBanner></div>}
|
||||
{draft !== null && draft !== saved && <div className="flex items-center justify-between">
|
||||
<Button variant="ghost" disabled={mutation.isPending} onClick={() => { setDraft(null); mutation.reset(); }}>Cancel</Button>
|
||||
<Button disabled={!canConfigure || mutation.isPending || !value.trim()} onClick={() => mutation.mutate()}>{mutation.isPending ? "Saving…" : "Save workspace"}</Button>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -37,7 +37,9 @@ function FieldHelp({ label, children }: { label: string; children: ReactNode })
|
||||
|
||||
/** Controlled presentation shared by provider setup, configuration imports and review stories.
|
||||
* Authentication, persistence and calls belong to the controller, never these views. */
|
||||
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, companyId, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName, onCancel }: {
|
||||
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, companyId, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName, onCancel, additionalSettings, settingsValid = true }: {
|
||||
additionalSettings?: ReactNode;
|
||||
settingsValid?: boolean;
|
||||
companyId: string;
|
||||
onCancel?: () => void;
|
||||
upstreamServiceName?: string;
|
||||
@@ -187,8 +189,9 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
</div>}
|
||||
</div>)}
|
||||
</fieldset>
|
||||
{additionalSettings}
|
||||
{busy && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin motion-reduce:animate-none" />Connecting and discovering tools…</p>}
|
||||
{footer(<>{s.setupComplete ? <Button type="button" variant="outline" disabled={busy} onClick={a.finish}>Back</Button> : <span />}<Button type="submit" disabled={busy || !s.url.trim()}>{busy ? "Connecting…" : error || s.connectStatus === "cancelled" ? "Try again" : `Connect ${provider.name}`}</Button></>)}
|
||||
{footer(<>{s.setupComplete ? <Button type="button" variant="outline" disabled={busy} onClick={a.finish}>Back</Button> : <span />}<Button type="submit" disabled={busy || !s.url.trim() || !settingsValid}>{busy ? "Connecting…" : error || s.connectStatus === "cancelled" ? "Try again" : `Connect ${provider.name}`}</Button></>)}
|
||||
</form>}
|
||||
</>}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ConnectionInstructionsEditor } from "../ConnectionInstructions";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared";
|
||||
import { isAppAggregator, aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps";
|
||||
import { REMOTE_MCP_CONNECTOR_METHODS, defaultConnectionAgentInstructions, getConnectableAppDefinition, type ToolConnection } from "@paperclipai/shared";
|
||||
import { askFirstCatalogEntryIdsFor } from "../connection-defaults";
|
||||
import { RemoteMcpAccountChoice } from "./RemoteMcpAccountChoice";
|
||||
import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
|
||||
@@ -30,6 +31,11 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
upstreamServiceName, requestedAgentId, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange,
|
||||
}: ConnectionSetupFlowProps & { providerId: RemoteMcpProviderId; connection?: ToolConnection }) {
|
||||
const provider = remoteMcpProviders[providerId];
|
||||
const template = getConnectableAppDefinition(providerId)?.agentInstructions;
|
||||
const [instructions, setInstructions] = useState(() => connection
|
||||
? connection.agentInstructions ?? (template ? { ...defaultConnectionAgentInstructions(template)!, enabled: false } : null)
|
||||
: defaultConnectionAgentInstructions(template));
|
||||
const instructionsValid = !instructions || Boolean(instructions.text.trim());
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -120,7 +126,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
else navigate(`/apps/${id}/permissions`);
|
||||
};
|
||||
const submit = async (saveDraft = false) => {
|
||||
if (busy.current || !selectedCompanyId || (connection && !installs.data)) return;
|
||||
if (busy.current || !selectedCompanyId || !instructionsValid || (connection && !installs.data)) return;
|
||||
try {
|
||||
const url = new URL(state.url.trim());
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
||||
@@ -153,6 +159,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
name: provider.name, link: state.url.trim(), grantKind: state.grantKind,
|
||||
authMode: state.auth === "headers" ? "custom_headers" : state.auth,
|
||||
credentialValues: credentials, saveDraft,
|
||||
...(template && instructions ? { agentInstructions: instructions } : {}),
|
||||
...(prior ? prior.status === "draft" ? { resumeConnectionId: prior.id } : { reconnectConnectionId: prior.id } : {}),
|
||||
});
|
||||
if (managementUrl) {
|
||||
@@ -248,5 +255,5 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
if (connection && !installs.data) return <div className="space-y-3 p-8"><p>{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}</p>{installs.isError && <button type="button" className="text-primary underline" onClick={() => void installs.refetch()}>Try again</button>}</div>;
|
||||
// Header Cancel abandons unsaved input, including invalid URLs. The separate
|
||||
// Save & exit action persists a resumable draft through actions.saveExit.
|
||||
return <RemoteMcpConnectionSetup companyId={selectedCompanyId!} onCancel={onCancel ?? (() => navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={authorizationUrl.current} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
return <RemoteMcpConnectionSetup additionalSettings={template && instructions ? <ConnectionInstructionsEditor provider={provider.name} template={template} value={instructions} onChange={setInstructions} disabled={state.connectStatus === "connecting"} /> : undefined} settingsValid={instructionsValid} companyId={selectedCompanyId!} onCancel={onCancel ?? (() => navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={authorizationUrl.current} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions";
|
||||
import type { AgentInstructionCandidate, AgentInstructionsBundle } from "@paperclipai/shared";
|
||||
import { InstructionHistory } from "../components/InstructionHistory";
|
||||
import { AgentCharacter } from "../components/AgentCharacter";
|
||||
@@ -1395,7 +1396,7 @@ export function AgentDetail() {
|
||||
)}
|
||||
|
||||
{activeView === "instructions" && (
|
||||
<PromptsTab
|
||||
<div><PromptsTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
showSaveNotice={false}
|
||||
@@ -1404,6 +1405,8 @@ export function AgentDetail() {
|
||||
onCancelActionChange={setCancelConfigAction}
|
||||
onSavingChange={setConfigSaving}
|
||||
/>
|
||||
{resolvedCompanyId && <AgentConnectionInstructions companyId={resolvedCompanyId} agentId={agent.id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === "runtime" && (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ConnectionInstructionsSettings } from "@/features/connections/ConnectionInstructions";
|
||||
import { HonchoWorkspaceSettings } from "@/features/connections/HonchoWorkspaceSettings";
|
||||
import { BrowserUseSettingsPanel } from "./app-detail/BrowserUseSettingsPanel";
|
||||
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
|
||||
import { RemoteMcpManagement } from "@/features/connections/remote-mcp/RemoteMcpManagement";
|
||||
@@ -72,8 +74,12 @@ import {
|
||||
|
||||
export { connectionAddress, connectionTransportLabel };
|
||||
|
||||
export function AppDetail({ renderActions, onReconnect }: {
|
||||
export function AppDetail({ renderActions, renderAgentSettings, renderConnectionSettings, onReconnect }: {
|
||||
renderActions?: (connection: ToolConnection) => ReactNode;
|
||||
/** Optional agent settings within Permissions, following the access controls. */
|
||||
renderAgentSettings?: (connection: ToolConnection) => ReactNode;
|
||||
/** Provider prerequisites shown before identity and agent access. */
|
||||
renderConnectionSettings?: (connection: ToolConnection) => ReactNode;
|
||||
onReconnect?: (connection: ToolConnection) => void;
|
||||
} = {}) {
|
||||
const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>();
|
||||
@@ -627,6 +633,8 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
: <div className="space-y-10">
|
||||
{isAppAggregator(brandKey) && grantsQuery.data?.capabilities.canConfigure === true
|
||||
? <ConnectedAggregatorApps key={connection.id} connection={connection} /> : null}
|
||||
{connection.config?.sourceTemplateKey === "honcho" && <HonchoWorkspaceSettings key={connection.id} connection={connection} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
|
||||
{renderConnectionSettings?.(connection)}
|
||||
{connection.config?.sourceTemplateKey === "browser-use-cloud" && <BrowserUseSettingsPanel connection={connection} grants={grantsQuery.data} />}
|
||||
{connection.config?.sourceTemplateKey === "railway" && <RailwayAccessPanel connection={connection} grants={grantsQuery.data} />}
|
||||
{connection.config?.provider === "agentmail" && <EmailConnectionInboxes companyId={connection.companyId} connectionId={connection.id} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
|
||||
@@ -667,6 +675,7 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
</p> : null}
|
||||
{connection.config?.sourceTemplateKey !== "composio" && isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) && <p className="text-sm text-muted-foreground">Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.</p>}
|
||||
<PermissionsPanel
|
||||
afterAgentAccess={<>{logoEntry?.agentInstructions && <ConnectionInstructionsSettings key={connection.id} connection={connection} provider={logoEntry.name} template={logoEntry.agentInstructions} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}{renderAgentSettings?.(connection)}</>}
|
||||
actions={actionsContent}
|
||||
connectionId={connectionId}
|
||||
capabilities={grantsQuery.data?.capabilities}
|
||||
|
||||
@@ -377,6 +377,45 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
return root;
|
||||
}
|
||||
|
||||
it("keeps Honcho setup blocked until the provider workspace is supplied", async () => {
|
||||
const honcho = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "honcho")!;
|
||||
experimentalMock.mockResolvedValue({ enableMemoryConnectors: true });
|
||||
listGalleryMock.mockResolvedValue({ apps: [honcho] });
|
||||
await render(undefined, false, <ConnectionSetupFlow serviceSlug="honcho" />);
|
||||
const key = container.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
const workspace = container.querySelector<HTMLInputElement>('input[aria-label="Honcho workspace"]')!;
|
||||
expect(workspace.compareDocumentPosition(key) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(container.textContent).toContain(honcho.agentInstructions!.text);
|
||||
expect(container.querySelector('[aria-label="Agent instructions"] [role="switch"]')?.getAttribute("aria-checked")).toBe("true");
|
||||
await act(async () => setInputValue(key, "test-honcho-key"));
|
||||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||||
await act(async () => buttonByText("Connect")?.click());
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
await act(async () => setInputValue(workspace, "paperclip-acme"));
|
||||
expect(buttonByText("Connect")?.disabled).toBe(false);
|
||||
await act(async () => buttonByText("Connect")?.click());
|
||||
await flushReact();
|
||||
expect(connectAppMock).toHaveBeenCalledOnce();
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
configValues: { workspaceId: "paperclip-acme" },
|
||||
agentInstructions: expect.objectContaining({ enabled: true, text: honcho.agentInstructions!.text }),
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([false, true])("shows optional instructions on the Notion OAuth screen only when supplied (%s)", async (provided) => {
|
||||
const template = { id: "notion.test", version: 1, text: "Look up published decisions in Notion before proposing changes." };
|
||||
listGalleryMock.mockResolvedValue({ apps: [{ ...NOTION, agentInstructions: provided ? template : undefined }] });
|
||||
await render(undefined, false, <ConnectionSetupFlow serviceSlug="notion" />);
|
||||
expect(buttonByText("Continue to Notion")).toBeDefined();
|
||||
const toggle = container.querySelector('[aria-label="Agent instructions"] [role="switch"]');
|
||||
expect(container.textContent?.includes("Tell agents to use Notion")).toBe(provided);
|
||||
if (provided) {
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(container.textContent).toContain(template.text);
|
||||
} else expect(toggle).toBeNull();
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["zapier", "arcade", "composio", "executor"])("inline aggregator %s collects the endpoint and completes only for the requester", async (provider) => {
|
||||
const onComplete = vi.fn();
|
||||
const popup = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ type ActionPermission = "off" | "ask" | "allowed";
|
||||
type ActionKindFilter = "all" | "read" | "write";
|
||||
|
||||
export function PermissionsPanel({
|
||||
afterAgentAccess,
|
||||
connectionId,
|
||||
appName,
|
||||
agents,
|
||||
@@ -61,6 +62,8 @@ export function PermissionsPanel({
|
||||
permissionChangeWarning?: string;
|
||||
/** A credential-only connection can supply its account controls instead of tool actions. */
|
||||
actions?: ReactNode;
|
||||
/** Supplemental agent settings share the existing connection configuration page. */
|
||||
afterAgentAccess?: ReactNode;
|
||||
}) {
|
||||
const [searchParams] = useSearchParams();
|
||||
return (
|
||||
@@ -73,6 +76,7 @@ export function PermissionsPanel({
|
||||
disabled={pending}
|
||||
onSave={onSaveAccess}
|
||||
/>
|
||||
{afterAgentAccess}
|
||||
{actions !== undefined ? actions : <ActionsSection
|
||||
key={connectionId}
|
||||
connectionId={connectionId}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { defaultConnectionAgentInstructions, getConnectableAppDefinition, instanceExperimentalSettingsSchema } from "@paperclipai/shared";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { storybookAgents } from "./paperclipData";
|
||||
import inventory from "../../../doc/connections/memory-tool-inventory.json";
|
||||
|
||||
export const GUIDANCE_COMPANY = "company-storybook";
|
||||
export const GUIDANCE_CONNECTION = "connection-guidance-preview";
|
||||
export const guidanceAgents = storybookAgents.slice(0, 2).map((agent, i) => ({ ...agent, name: i ? "Morgan" : "Ada" }));
|
||||
|
||||
/** All connector requests remain in the story, including mutations from the real pages. */
|
||||
export function installConnectionGuidanceFixtures(client: QueryClient, provider: "Honcho" | "Notion", options: { setup: boolean; askFirst: boolean; template?: string | null; disabled?: boolean; missingWorkspace?: boolean; failSave?: boolean }) {
|
||||
const company = GUIDANCE_COMPANY;
|
||||
const id = GUIDANCE_CONNECTION;
|
||||
const app = structuredClone(getConnectableAppDefinition(provider.toLowerCase())!);
|
||||
if (options.template === null) delete app.agentInstructions;
|
||||
else if (options.template) app.agentInstructions = { id: `${app.slug}.storybook`, version: 1, text: options.template };
|
||||
let failSave = options.failSave ?? false;
|
||||
const timestamp = "2026-10-04T12:00:00.000Z";
|
||||
let connected = !options.setup;
|
||||
const connection = {
|
||||
id, uid: id, companyId: company, applicationId: "guidance-app", name: provider,
|
||||
connectionPurpose: "tool", credentialPolicy: "shared", transport: "mcp_remote",
|
||||
authKind: provider === "Honcho" ? "api_key" : "oauth", status: "active", enabled: true,
|
||||
healthStatus: "ok", healthMessage: null, requiresReauthorization: false,
|
||||
createdByUserId: "user-storybook", createdByAgentId: null,
|
||||
agentInstructions: app.agentInstructions ? { ...defaultConnectionAgentInstructions(app.agentInstructions)!, enabled: !options.disabled } : null,
|
||||
config: { methodConfig: { workspaceId: options.missingWorkspace ? "" : "paperclip-acme" }, sourceTemplateKey: app.slug, connectionMethodKey: app.methods[0]?.key, url: app.methods[0]?.defaults?.serverUrl },
|
||||
createdAt: timestamp, updatedAt: timestamp,
|
||||
};
|
||||
const application = { id: "guidance-app", companyId: company, applicationKey: app.slug, name: app.name, status: "active", metadata: { sourceTemplateKey: app.slug } };
|
||||
const observed = provider === "Honcho" ? inventory.providers.find(p => p.provider === "honcho")!.tools : [
|
||||
{ name: "search", risk: "read", parameters: ["query"] },
|
||||
{ name: "fetch", risk: "read", parameters: ["id"] },
|
||||
{ name: "create_page", risk: "write", parameters: [] },
|
||||
];
|
||||
const catalog = observed.map(tool => ({
|
||||
id: `guidance-${tool.name}`, companyId: company, applicationId: application.id, connectionId: id,
|
||||
entryKind: "tool", toolName: tool.name, title: tool.name.replaceAll("_", " "), description: `${provider} · ${tool.name}`,
|
||||
inputSchema: { type: "object", properties: {} }, outputSchema: null, annotations: null,
|
||||
riskLevel: tool.risk, isReadOnly: tool.risk === "read", isWrite: tool.risk === "write", isDestructive: tool.risk === "destructive",
|
||||
status: "active", version: null, schemaHash: "storybook", addedAt: timestamp, createdAt: timestamp, updatedAt: timestamp,
|
||||
}));
|
||||
const capabilities = { canConfigure: true, canCreateOrganizationGrant: true, canSetCompanyInstall: true, canConnectAsCurrentUser: true, canManageAgentInstalls: true, canViewOtherPersonalIdentities: true };
|
||||
let profile = { profileKey: `app:${id}`, entries: catalog.map(entry => ({ effect: "include", catalogEntryId: entry.id })), bindings: guidanceAgents.map(agent => ({ targetType: "agent", targetId: agent.id })) };
|
||||
let policies = options.askFirst ? catalog.filter(t => !t.isReadOnly).map(t => ({ policyType: "require_approval", enabled: true, config: { source: "app_gallery_finish", connectionId: id, catalogEntryId: t.id } })) : [];
|
||||
let installs: Array<{ targetType: string; targetId: string }> = [{ targetType: "company", targetId: company }];
|
||||
const grants = { connection: { id, uid: id }, capabilities, currentUserId: "user-storybook", members: [], grants: [{
|
||||
id: "guidance-grant", companyId: company, connectionId: id, kind: "organization", subjectUserId: null, subjectAgentId: null,
|
||||
status: "active", isDefault: true, providerTenant: { name: "Acme" }, members: [], capabilities: { canRevoke: true, canEditAudience: true },
|
||||
createdAt: timestamp, updatedAt: timestamp, credentialSecretRefs: [],
|
||||
}] };
|
||||
const gallery = { apps: [app], capabilities };
|
||||
const settings = { ...instanceExperimentalSettingsSchema.parse({}), enableMemoryConnectors: true };
|
||||
const directory = { users: [{ principalId: "user-storybook", status: "active", user: {
|
||||
id: "user-storybook", email: "[email protected]", name: "Riley Board", image: null,
|
||||
} }] };
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, settings);
|
||||
const responses = new Map<string, () => unknown>([
|
||||
[`/api/companies/${company}/user-directory`, () => directory],
|
||||
[`/api/companies/${company}/agents`, () => guidanceAgents],
|
||||
[`/api/companies/${company}/tools/gallery`, () => gallery],
|
||||
[`/api/companies/${company}/tools/connections`, () => ({ connections: connected ? [connection] : [] })],
|
||||
[`/api/companies/${company}/tools/applications`, () => ({ applications: connected ? [application] : [] })],
|
||||
[`/api/tool-connections/${id}`, () => connection],
|
||||
[`/api/tool-connections/${id}/catalog`, () => ({ catalog })],
|
||||
[`/api/tool-connections/${id}/grants`, () => grants],
|
||||
[`/api/tool-connections/${id}/installs`, () => ({ connectionId: id, installs })],
|
||||
[`/api/companies/${company}/tools/profiles`, () => ({ profiles: [profile] })],
|
||||
[`/api/companies/${company}/tools/policies`, () => ({ policies })],
|
||||
]);
|
||||
for (const agent of guidanceAgents) responses.set(`/api/companies/${company}/tools/profiles/effective/agents/${agent.id}`, () => ({ agentId: agent.id, profiles: [], entries: [], bindings: [], allowedTools: catalog, allowedToolNames: catalog.map(t => t.toolName), installedConnections: [connection] }));
|
||||
const seeds: Array<[readonly unknown[], unknown]> = [
|
||||
[queryKeys.access.companyUserDirectory(company), directory],
|
||||
[queryKeys.agents.list(company), guidanceAgents], [queryKeys.apps.gallery(company), gallery],
|
||||
[queryKeys.tools.connection(id), connection], [queryKeys.tools.catalog(id), { catalog }],
|
||||
[queryKeys.tools.connectionGrants(id), grants], [queryKeys.tools.connectionInstalls(id), { connectionId: id, installs }],
|
||||
[queryKeys.tools.connections(company), { connections: connected ? [connection] : [] }],
|
||||
[queryKeys.tools.applications(company), { applications: connected ? [application] : [] }],
|
||||
[queryKeys.tools.profiles(company), { profiles: [profile] }], [queryKeys.tools.policies(company), { policies }],
|
||||
];
|
||||
for (const [key, value] of seeds) client.setQueryData(key, value);
|
||||
const original = window.fetch;
|
||||
const fixture: typeof window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
|
||||
const path = url.pathname;
|
||||
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
||||
const body = () => JSON.parse(String(init?.body ?? "{}"));
|
||||
if (path === "/api/instance/settings/experimental") return Response.json(settings);
|
||||
if (path === `/api/companies/${company}/tools/apps/connect`) {
|
||||
connected = true;
|
||||
const next = body();
|
||||
if (next.agentInstructions !== undefined) connection.agentInstructions = next.agentInstructions;
|
||||
if (next.configValues !== undefined) connection.config.methodConfig = next.configValues;
|
||||
return Response.json({ connectionId: id, connection, application, catalog,
|
||||
actions: { readOnly: catalog.filter(t => t.isReadOnly), canMakeChanges: catalog.filter(t => !t.isReadOnly) }, suggestedDefaults: {}, auth: null });
|
||||
}
|
||||
if (path.endsWith(`/apps/${id}/finish`)) {
|
||||
const next = body();
|
||||
if (next.agentInstructions !== undefined) connection.agentInstructions = next.agentInstructions;
|
||||
profile = { ...profile, entries: (next.enabledCatalogEntryIds ?? []).map((catalogEntryId: string) => ({ effect: "include", catalogEntryId })),
|
||||
bindings: next.access === "all_agents" ? [{ targetType: "company", targetId: company }] : (next.access?.agentIds ?? []).map((targetId: string) => ({ targetType: "agent", targetId })) };
|
||||
policies = (next.askFirstCatalogEntryIds ?? []).map((catalogEntryId: string) => ({ policyType: "require_approval", enabled: true, config: { source: "app_gallery_finish", connectionId: id, catalogEntryId } }));
|
||||
return Response.json({ connectionId: id, installs });
|
||||
}
|
||||
if (path === `/api/tool-connections/${id}/installs` && method === "PUT") installs = body().installs ?? [];
|
||||
if (path === `/api/tool-connections/${id}` && method === "PATCH") {
|
||||
if (failSave) { failSave = false; return Response.json({ error: "Couldn’t save instructions. Your changes are still here. Try again." }, { status: 503 }); }
|
||||
Object.assign(connection, body());
|
||||
return Response.json(connection);
|
||||
}
|
||||
if (method === "GET" && responses.has(path)) return Response.json(responses.get(path)!());
|
||||
if (path.endsWith("/preflight")) return Response.json({ oauth: { metadataFound: true, registrationAdvertised: true }, endpointReachable: true });
|
||||
if (path.includes(`/tool-connections/${id}/`) || path.includes(`/tools/apps/${id}/`)) {
|
||||
if (path.endsWith("/catalog/refresh")) return Response.json({ catalog, discoveredCount: catalog.length, quarantinedCount: 0 });
|
||||
if (path.endsWith("/test-agents")) return Response.json({ agents: guidanceAgents });
|
||||
// Unimplemented test/credential operations must not claim a real provider result.
|
||||
if (path.includes("test-")) return Response.json({ error: "Provider execution is not included in this design preview." }, { status: 422 });
|
||||
return Response.json({ connectionId: id, installs });
|
||||
}
|
||||
if (path.startsWith("/api/tools/oauth/")) return Response.json({ error: "Sign-in is not available in this design preview. No provider was contacted." }, { status: 422 });
|
||||
if (path.startsWith(`/api/companies/${company}/tools/`) || path.startsWith("/api/tool-connections/") || path.startsWith("/api/tools/")) {
|
||||
return Response.json({ error: "This connector operation is not included in this design preview." }, { status: 422 });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
window.fetch = fixture;
|
||||
return () => { if (window.fetch === fixture) window.fetch = original; };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getConnectableAppDefinition } from "@paperclipai/shared";
|
||||
import { Route, Routes, useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { AppDetail } from "@/pages/apps/AppDetail";
|
||||
import { Browse } from "@/pages/apps/Browse";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, guidanceAgents, installConnectionGuidanceFixtures } from "../fixtures/connectionInstructions";
|
||||
|
||||
const HONCHO = getConnectableAppDefinition("honcho")!.agentInstructions!.text;
|
||||
const NOTION = "Use Notion as the source for our published product decisions. Look up the relevant decision before proposing a change, and link to the page you used. Ask before changing a published decision. If a page is unavailable, say which context is missing and continue with the information you have.";
|
||||
type Scenario = "normal" | "off" | "save-error" | "missing-context" | "write-approval" | "reconnect";
|
||||
type Props = { initialView?: "connect" | "connection" | "agent"; provider?: "Honcho" | "Notion"; scenario?: Scenario; baseline?: boolean; providedInstructions?: string };
|
||||
|
||||
/** Production pages and editors with stateful API fixtures. */
|
||||
function ConnectionInstructionsPrototype({ initialView = "connection", provider = "Honcho", scenario = "normal", baseline = false, providedInstructions }: Props) {
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const [ready, setReady] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(initialView === "agent");
|
||||
const connectionPath = `/PAP/apps/${GUIDANCE_CONNECTION}/permissions`;
|
||||
useEffect(() => installConnectionGuidanceFixtures(client, provider, {
|
||||
setup: initialView === "connect" && scenario !== "reconnect", askFirst: scenario === "write-approval",
|
||||
template: baseline ? null : providedInstructions ?? null, disabled: scenario === "off",
|
||||
missingWorkspace: scenario === "missing-context", failSave: scenario === "save-error",
|
||||
}), [client, provider, initialView, scenario, baseline, providedInstructions]);
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
setSelectedCompanyId(GUIDANCE_COMPANY);
|
||||
navigate(initialView === "connect"
|
||||
? `/PAP/apps/connect?source=${provider.toLowerCase()}${scenario === "reconnect" ? `&reconnect=${GUIDANCE_CONNECTION}` : ""}`
|
||||
: connectionPath, { replace: true });
|
||||
setReady(true);
|
||||
}, [provider, initialView, scenario, navigate, setSelectedCompanyId, connectionPath]);
|
||||
if (!ready || selectedCompanyId !== GUIDANCE_COMPANY) return null;
|
||||
return <PluginLauncherProvider>
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/connect" element={<ConnectionSetupFlow onComplete={() => navigate(connectionPath)} />} />
|
||||
<Route path="apps/:connectionId/:tab?" element={<><AppDetail /><Button variant="ghost" onClick={() => setPreviewOpen(true)}>Preview on Ada</Button></>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader><DialogTitle>Instructions for Ada</DialogTitle><DialogDescription>Instructions provided by assigned connections.</DialogDescription></DialogHeader>
|
||||
<AgentConnectionInstructions companyId={GUIDANCE_COMPANY} agentId={guidanceAgents[0]!.id} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PluginLauncherProvider>;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Design explorations/Connections/Agent instructions",
|
||||
component: ConnectionInstructionsPrototype,
|
||||
args: { providedInstructions: HONCHO },
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Production Layout, ConnectionSetupFlow, AppDetail, identity/access controls, and searchable Actions list with local API fixtures. The production instructions editor saves and reloads through mocked APIs. Baseline stories omit template metadata. No provider calls." } } },
|
||||
} satisfies Meta<typeof ConnectionInstructionsPrototype>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ConnectHoncho: Story = { args: { initialView: "connect" } };
|
||||
export const ConnectionSettings: Story = {};
|
||||
export const CurrentConnectPage: Story = { args: { initialView: "connect", baseline: true } };
|
||||
export const CurrentConfigurationPage: Story = { args: { baseline: true } };
|
||||
export const ExistingConnectionOff: Story = { args: { scenario: "off" } };
|
||||
export const InstructionsOnAgent: Story = { args: { initialView: "agent" } };
|
||||
export const InstructionsOffOnAgent: Story = {
|
||||
args: { initialView: "agent", scenario: "off" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(await page.findByRole("dialog"));
|
||||
await expect(await dialog.findByText("These instructions are turned off for this connection.")).toBeVisible();
|
||||
await expect(dialog.getByText(HONCHO)).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const WorkspaceNeeded: Story = { args: { scenario: "missing-context" } };
|
||||
export const ConnectWorkspaceNeeded: Story = { args: { initialView: "connect", scenario: "missing-context" } };
|
||||
export const WritesAskFirst: Story = { args: { scenario: "write-approval" } };
|
||||
export const ReconnectKeepsInstructions: Story = { args: { initialView: "connect", scenario: "reconnect" } };
|
||||
export const NotionOptionalInstructions: Story = { args: { initialView: "connect", provider: "Notion", providedInstructions: NOTION }, parameters: { docs: { description: { story: "Example of a non-memory connection explicitly supplying an instruction template." } } } };
|
||||
export const NotionWithoutInstructions: Story = { args: { initialView: "connect", provider: "Notion", providedInstructions: undefined } };
|
||||
export const ConnectionWithoutInstructions: Story = { args: { provider: "Notion", providedInstructions: undefined } };
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
export const Mobile: Story = { args: { initialView: "connect" }, globals: { viewport: { value: "mobile", isRotated: false } } };
|
||||
|
||||
export const EditAndPreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Edit instructions" }));
|
||||
const field = canvas.getByRole("textbox", { name: "Instructions text" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "Recall release decisions before planning. Save confirmed changes with a source link.");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
|
||||
await waitFor(() => expect(canvas.queryByRole("button", { name: "Save instructions" })).not.toBeInTheDocument());
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Preview on Ada" }));
|
||||
const dialog = within(canvasElement.ownerDocument.body).getByRole("dialog");
|
||||
await expect(within(dialog).getByText("Recall release decisions before planning. Save confirmed changes with a source link.")).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const DisableKeepsText: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("switch", { name: "Tell agents to use Honcho" }));
|
||||
await expect(canvas.getByText("Agents can still use this connection’s tools, but won’t receive these instructions.")).toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
|
||||
await waitFor(() => expect(canvas.queryByRole("button", { name: "Save instructions" })).not.toBeInTheDocument());
|
||||
await expect(canvas.getByText(HONCHO)).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const SaveFailure: Story = {
|
||||
args: { scenario: "save-error" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Edit instructions" }));
|
||||
await userEvent.type(canvas.getByRole("textbox", { name: "Instructions text" }), " Include source links.");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent("Your changes are still here");
|
||||
await expect(canvas.getByRole("textbox", { name: "Instructions text" })).toHaveValue(`${HONCHO} Include source links.`);
|
||||
},
|
||||
};
|
||||
|
||||
export const ResetToDefault: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Edit instructions" }));
|
||||
const field = canvas.getByRole("textbox", { name: "Instructions text" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "Look up prior release decisions.");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
|
||||
await waitFor(() => expect(canvas.queryByRole("button", { name: "Save instructions" })).not.toBeInTheDocument());
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Reset to default" }));
|
||||
await expect(field).toHaveValue(HONCHO);
|
||||
await expect(canvas.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
|
||||
await expect(canvas.getByRole("switch", { name: "Tell agents to use Honcho" })).toBeChecked();
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user