Merge master and retain the shared Lucide compatibility fix

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-01 12:16:01 -05:00
commit 56f6c416af
63 files changed
+2703 -1928

No files matched your search

+28 -24
View File
@@ -144,6 +144,21 @@ RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
RUN rm -rf packages/paperclip-runner/runner/target
# Remote OpenCode and ACPX runs require a controller-owned provider pack to
# verify the sandbox installation or stage matching assets. Ship it in the
# standard image so downstream Cloud compositions inherit the same artifacts.
# Grok's native executable stays an external sandbox prerequisite; this pack
# contains only its launcher.
FROM build AS runner-provider-pack
# Unstamped local builds remain usable, but cannot qualify a remote pack.
# Never invent a source revision to make an unqualified pack look verified.
RUN mkdir -p /provider-pack \
&& if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \
PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack; \
else \
echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote OpenCode/ACPX execution"; \
fi
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
@@ -169,6 +184,17 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --chown=node:node --from=build /app /app
COPY --from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack
# Managed deployments can remap node's UID at startup. This immutable pack
# contains public code and integrity metadata, never credentials; it must remain
# readable afterward.
# Keep it root-owned and verify access as an unrelated unprivileged UID.
RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \
&& if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \
gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \
fi
ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack
# Declare per-build metadata after the stable RUN layers. Docker includes
# in-scope ARG values in a RUN's environment even when its command does not
# mention them; declaring these earlier invalidates the weekly tool cache.
@@ -213,8 +239,8 @@ CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/di
# dist/ to exist in the image — the default image ships only their source,
# so auto-install logs "bundle not present" and skips. The plugins are
# built in this separate target so the default (self-hosted) image stays
# lean; CI pins the default build to `--target production`, which is
# byte-identical to before this stage existed.
# lean; CI pins the default build to `--target production`. Both targets
# inherit the build-owned remote provider pack from the standard image.
#
# The sandbox providers are intentionally excluded from the pnpm workspace
# (see pnpm-workspace.yaml), so each installs standalone exactly as its
@@ -293,29 +319,7 @@ RUN set -eu; \
test -n "$specifiers" || { echo "ERROR: CLOUD_BUNDLED_SERVER_DEPS names no package" >&2; exit 1; }; \
pnpm add --ignore-workspace --no-lockfile $specifiers
# ACPX remote runs require a controller-owned provider pack to verify the
# sandbox installation or stage matching assets. Grok's native executable stays
# an external sandbox prerequisite; this pack contains only its launcher.
FROM build AS cloud-provider-pack
# Unstamped local builds remain usable, but cannot qualify a remote pack.
# Never invent a source revision to make an unqualified pack look verified.
RUN mkdir -p /provider-pack \
&& if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \
PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack; \
else \
echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote ACPX execution"; \
fi
FROM production AS cloud
COPY --from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack
# Cloud remaps node's UID at startup. This immutable pack contains public code
# and integrity metadata, never credentials; it must remain readable afterward.
# Keep it root-owned and verify access as an unrelated unprivileged UID.
RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \
&& if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \
gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \
fi
ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack
COPY --chown=node:node --from=cloud-plugins /app/packages/plugins/sandbox-providers /app/packages/plugins/sandbox-providers
# Land the isolated install inside the server's own `node_modules`, the
# directory Node's module resolution walks up to from `/app/server` for
+17 -2
View File
@@ -100,6 +100,20 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
Use **Components → Agent setup prompt** to review the shared setup handoff:
hover/focus logo motion, one-click copying with a prompt preview, animated
confirmation, and manual-copy recovery. Opening the preview copies immediately;
clicking its trigger again copies without closing. Close and Escape only dismiss;
the inner copy button remains available for retries and later copies. Stories include Slack and API copy, a compact quick-access placement,
light and mobile views, and a clipboard paste check. `AgentSetupPrompt` accepts
the complete `prompt`, `title`, `description`, trigger `label`, and popover
placement. Use it for prompts handed to an external agent: Slack and GitHub
connections, MCP configuration help, routine webhook setup, external-agent
invitations, and task continuation. The **App placements** story collects the
production controls; **Inside MCP help** exercises the popover inside a dialog.
Prompt generation can pass `initialCopyStatus` to preserve its automatic copy
result, and `onCopied` to clear an earlier generation-time clipboard error.
Use **Design explorations → Agent chat sidebar** to review the production secondary
agent navigation. **Components** covers selection, search, loading, empty results,
and larger teams. **Pages** puts it beside the production agent chat inside the
@@ -823,8 +837,9 @@ The default `worktree init` still seeds eagerly. A lean worktree (created withou
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source from the control-plane-provided base project workspace when it carries its own `.paperclip/config.json`, and otherwise from the control plane's own registered instance config; either way the workspace's manifest never selects it. Manual worktrees must pass `--from-config`.
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config <source-config>`.
- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`, so the isolated config and pending manifest cannot be silently skipped. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence.
- If the built-in provisioner reports an unavailable seed source config, decide whether the task needs a seeded development instance or only an isolated checkout. A seeded instance needs a canonical config from the registered base workspace or control-plane instance; environment-only server configuration does not provide that file. For a checkout-only worktree, explicitly set the `git_worktree` strategy's `provisionCommand` to `"true"`. This skips setup and does not establish runtime or seed readiness. Repair rejected symlinks or non-regular source files instead of treating those validation failures as a missing prerequisite.
- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`. When a registered source config exists, setup creates the isolated config and pending manifest. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence.
- An environment-configured server may have no local seed config. If neither the base checkout nor the default control-plane instance has one, a fresh worktree prepares its dependencies without creating a development instance. This also applies when `PAPERCLIP_CONFIG` names the default `$PAPERCLIP_HOME/instances/$PAPERCLIP_INSTANCE_ID/config.json` path, as the Docker image does. Setup creates no config, environment file, or seed manifest and does not claim runtime or seed readiness. A later request for a seeded development runtime still needs a canonical registered source config. Once that source exists, provisioning the checkout again creates the development instance normally.
- A missing custom `PAPERCLIP_CONFIG`, rejected symlink, or non-regular source file still fails setup. An existing worktree with a config, environment file, or seed state also fails if its source disappears; setup never downgrades that instance to a plain checkout. Repair the source before retrying.
- The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata.
- Worktrees created before lazy seeding shipped may have neither marker. Paperclip adopts them only after their configured database proves a compatible migration journal and the core Paperclip schema; otherwise managed startup creates a pending manifest and performs the normal verified seed. Manual markerless worktrees must provide `--from-config` so the source remains explicit.
+13
View File
@@ -40,6 +40,19 @@ and ARM64. Canonical master pushes also publish
for its immutable multi-platform digest. Downstream services can compose their
own images from this public base without rebuilding Core.
Stamped standard images include the build-owned remote provider pack at
`/opt/paperclip-runner/provider-pack` and set
`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` to that directory. Downstream
compositions and the explicit `cloud` target inherit both. The pack is required
on the controller for remote OpenCode and ACPX runs, even when the sandbox has
preinstalled providers; setting the environment variable alone cannot repair an
image that omits the pack. Rebuild and redeploy from a standard image containing
the pack if runs fail with `runner_remote_provider_artifact_incompatible`.
Local Docker builds must supply a full `PAPERCLIP_BUILD_COMMIT` to include a
qualified pack. Unstamped builds remain usable for local adapters and skip pack
generation. The pack stays root-owned and readable by remapped runtime UIDs.
The legacy recurring public `-cloud` publisher is retired. Master pushes,
release tags, and manual `Docker` dispatches no longer build that variant.
Existing `-cloud` tags and digests remain in the registry for rollback; their
+2
View File
@@ -1661,6 +1661,8 @@ for persistence, migration, rendering, and integration contracts.
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
The Chat navigation entry reopens the last agent conversation visited by the current user in the current company. The browser keeps this recent order and any existing conversation ID locally; unavailable agents and removed conversations are skipped. An agent chat that has no issue yet can still reopen from the agent roster. The agent chooser remains the landing view when no saved chat is available.
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
+56
View File
@@ -0,0 +1,56 @@
# Asana
Paperclip uses [Asana's official remote MCP server](https://developers.asana.com/docs/integrating-with-asanas-mcp-server)
at `https://mcp.asana.com/v2/mcp`. The default connection uses Paperclip's
shared OAuth app through the Cloud connector broker. An enrolled self-hosted
instance can use that same app. **Use your own Asana OAuth app** is available
without enrolling the instance, including when the shared app is unavailable.
## Your own app
1. Open [Asana My apps](https://app.asana.com/0/my-apps) and create an
**Asana MCP** app. An API app or personal access token will not authorize MCP.
2. Under OAuth, copy the exact callback displayed in Paperclip into Redirect URLs.
For local development Paperclip uses `localhost`, even if opened at `127.0.0.1`.
Public deployments should configure their canonical HTTPS URL.
3. Under Manage distribution, select the workspaces that can use the app and save.
4. Enter the client ID and client secret in Paperclip and continue to Asana.
MCP v2 still requires OAuth and a preregistered client; it does not offer dynamic
client registration. Asana MCP currently grants one fixed `default` scope.
Paperclip's action permissions govern which discovered tools an agent can use.
The client secret is encrypted in the local credential vault and sent only to
the token endpoint. Resuming setup with the same client ID preserves the secret;
changing the client ID requires the matching new secret.
## Discovery and lifecycle
Asana's live v2 resource challenge points to
`https://mcp.asana.com/.well-known/oauth-protected-resource/v2`. The root metadata
still describes the retired v1 issuer. The curated method pins the v2 discovery
URL and re-resolves it for saved connections, repairing drafts that cached v1.
Saved manual-client bindings from that exact v1 issuer/resource are repaired for
Asana v2 while preserving company and callback checks. Other binding changes
still require new credentials. Secret reuse is shown only when the requesting
user has an active grant with a saved client-secret reference.
Authorization and token exchange use `https://app.asana.com/-/oauth_authorize`
and `https://app.asana.com/-/oauth_token`, with PKCE S256.
The managed `asana.mcp` profile uses the existing signed Cloud connector protocol,
including instance-bound encrypted token delivery and refresh. Availability is
advertised only after the broker has its MCP app credentials and profile enabled.
The shared app's provider secret is held by the broker, never distributed to
instances. Rolling out the shared option requires the companion broker support,
app registration, allowed distribution, and provider configuration.
## Verify
Test both managed and custom sign-in in the browser. Confirm that callback and
workspace selection succeed, tools load, and a read-only tool such as **Get me**
works through **Test** as an installed agent. Also verify canceled/interrupted
setup can resume, and that refresh works after token expiration. Never place
provider tokens or client secrets in screenshots or test fixtures.
Also connect a second account after renaming the first connection. Both custom
and shared app connections must finish with separate permission profiles, even
when the first profile still uses the provider's original name.
+3
View File
@@ -513,6 +513,9 @@ connection work or enforce a real tenant boundary. Follow these rules:
controls and per-action Test dialog. Do not build a parallel permissions list or
provider-specific testing page. Discovery errors stay inline on Connect; an empty
returned catalog belongs to the ordinary saved-connection state.
- The per-action Test dialog shows structured MCP results as readable fields,
tables, or cards when possible. It keeps the full raw response available for
diagnosis and opens it by default when the result cannot be rendered safely.
- Enable every discovered tool automatically. Put later Allowed / Ask first / Off
controls in management, separate from connection access. Reconnect and refresh
retain existing restrictions; new tools are Allowed under the existing access rules.
+1 -1
View File
@@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
Long-term memory: [Experimental memory connectors](./MEMORY.md).
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md),
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md),
[AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody:
[Vercel Connect](./VERCEL-CONNECT.md).
@@ -56,6 +56,23 @@
"reviewedAt": "2026-09-30",
"liveProof": "not-run"
},
{
"app": "asana",
"method": "managed",
"auth": "oauth",
"policy": "explicit",
"requestedScopes": [
"default"
],
"capability": "write",
"supportedActions": "Create/update tasks and projects authorized by the account.",
"evidence": [
"https://developers.asana.com/docs/integrating-with-asanas-mcp-server",
"https://mcp.asana.com/.well-known/oauth-protected-resource/v2"
],
"reviewedAt": "2026-09-30",
"liveProof": "Real OAuth, 39 MCP actions, Get me, and forced-expiry refresh verified with a local broker fixture on 2026-09-30."
},
{
"app": "asana",
"method": "mcp-own-oauth",
@@ -68,10 +85,10 @@
"supportedActions": "Create/update tasks and projects authorized by the account.",
"evidence": [
"https://developers.asana.com/docs/integrating-with-asanas-mcp-server",
"https://mcp.asana.com/.well-known/oauth-protected-resource"
"https://mcp.asana.com/.well-known/oauth-protected-resource/v2"
],
"reviewedAt": "2026-09-30",
"liveProof": "not-run"
"liveProof": "OAuth, 39 MCP actions, and Get me verified in the embedded browser on 2026-09-30."
},
{
"app": "beehiiv",
+1 -1
View File
@@ -25,7 +25,7 @@ All environment variables that Paperclip uses for server configuration.
| `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` | (host build) | Host-local path to a `paperclip-runnerd` artifact built for the remote target OS and architecture. Required when Paperclip and the remote sandbox do not share a compatible platform; build metadata and the required transport mode are verified before launch. |
| `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH` | (unset) | Optional host-local path to a Codex executable built for the remote target OS and architecture. For remote Codex-backed runners, Paperclip stages and verifies this executable beside `paperclip-runnerd`. |
| `PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` | (unset) | Optional pinned npm package spec (for example, `@openai/codex@0.156.0`) installed inside each fresh remote lease when its Codex harness is not baked into the sandbox image. Mutually exclusive with `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH`; Paperclip verifies the installed executable before starting `runnerd`. |
| `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` | (unset) | Host-local path to the immutable provider pack built by `pnpm --filter @paperclipai/paperclip-runner build:provider-pack`. The pack includes its target-built Node 24.11 runtime, locked production dependencies, OpenCode proxy/executable, and ACPX sidecar. Remote OpenCode and ACPX fail closed without it. A preinstalled pack is accepted only when its complete digested manifest matches this build-owned pack; otherwise Paperclip stages this pack into the sandbox. |
| `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` | `/opt/paperclip-runner/provider-pack` in Docker; otherwise unset | Host-local path to the immutable provider pack built by `pnpm --filter @paperclipai/paperclip-runner build:provider-pack`. Stamped standard Docker images include the pack; downstream compositions and the `cloud` target inherit it. Unstamped local Docker builds skip pack generation. The pack includes its target-built Node 24.11+ runtime, locked production dependencies, OpenCode proxy/executable, and ACPX sidecar. Remote OpenCode and ACPX fail closed without it. A preinstalled pack is accepted only when its complete digested manifest matches this build-owned pack; otherwise Paperclip stages this pack into the sandbox. |
| `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). |
| `PAPERCLIP_SETTING_DEFAULTS` | (unset) | JSON object replacing the schema default of selected instance settings, for hosting operators. See [Operator setting defaults](#operator-setting-defaults). |
+9 -4
View File
@@ -572,10 +572,15 @@ describe("AppDefinition catalog", () => {
(field) => field.key === "readOnly",
)?.defaultValue,
).toBe(false);
// Asana and Linear both advertise dynamic client registration and issue
// clients on request (verified live 2026-09-28), so neither needs an
// operator-registered OAuth app. "customer" stays as the manual fallback.
expect(method("asana")?.ownershipModes).toEqual(["dcr", "customer"]);
expect(method("asana")).toMatchObject({
key: "managed", ownershipModes: ["platform_shared"], connectorProfile: "asana.mcp",
defaults: { serverUrl: "https://mcp.asana.com/v2/mcp", scopesHint: ["default"] },
});
expect(APP_DEFINITIONS.find((app) => app.slug === "asana")?.methods[1]).toMatchObject({
key: "mcp-own-oauth", ownershipModes: ["customer"], oauthClientSecretRequired: true,
defaults: { discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" },
consoleLinks: { register: "https://app.asana.com/0/my-apps" },
});
expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]);
expect(method("zapier")).toMatchObject({
key: "generated-url",
+37 -7
View File
@@ -17,27 +17,57 @@
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-own-oauth",
"key": "managed",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr",
"customer"
"platform_shared"
],
"whenToUse": "Sign in with Asana. Paperclip registers its own OAuth client automatically.",
"whenToUse": "Connect your Asana account with Paperclip's app.",
"defaults": {
"serverUrl": "https://mcp.asana.com/v2/mcp",
"scopesHint": [
"default"
]
},
"guidanceMd": "Connect Asana in the browser. Paperclip registers its own OAuth client with Asana's MCP server, so a local Paperclip needs no developer-console setup. Asana only accepts localhost callbacks, so a hosted deployment falls back to entering your own OAuth app.",
"guidanceMd": "Sign in to Asana with Paperclip. Asana gives this connection access to the workspaces available to your account.",
"riskTier": "S3",
"label": "Sign in with Asana",
"consoleLinks": {
"register": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server",
"docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server"
}
},
"warnings": [],
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "asana.mcp",
"grantKinds": [
"user",
"agent"
]
},
{
"key": "mcp-own-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"customer"
],
"whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.",
"defaults": {
"serverUrl": "https://mcp.asana.com/v2/mcp",
"discoveryUrl": "https://mcp.asana.com/.well-known/oauth-protected-resource/v2",
"scopesHint": [
"default"
]
},
"guidanceMd": "Create an MCP app in Asana, then add the callback URL below under OAuth. Under Manage distribution, select your workspace and save. API apps do not work with Asana MCP.",
"riskTier": "S3",
"label": "Use your own OAuth app",
"consoleLinks": {
"register": "https://app.asana.com/0/my-apps",
"docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server"
},
"warnings": [],
"oauthClientSecretRequired": true
}
]
}
+10
View File
@@ -0,0 +1,10 @@
export const ASANA_CONNECTOR_PROFILE_ID = "asana.mcp" as const;
export type AsanaConnectorProfileId = typeof ASANA_CONNECTOR_PROFILE_ID;
export const ASANA_MCP_URL = "https://mcp.asana.com/v2/mcp";
export const ASANA_MCP_DISCOVERY_URL = "https://mcp.asana.com/.well-known/oauth-protected-resource/v2";
export const ASANA_CONNECTOR_SCOPES = ["default"] as const;
export function isAsanaConnectorProfileId(value: string): value is AsanaConnectorProfileId {
return value === ASANA_CONNECTOR_PROFILE_ID;
}
@@ -17,22 +17,22 @@ import {
*/
describe("connectionSetupStateForApp", () => {
it("puts one-click OAuth connectors in the authorize state", () => {
for (const slug of ["notion", "linear", "sentry", "stripe", "jira", "asana"]) {
for (const slug of ["notion", "linear", "sentry", "stripe", "jira"]) {
expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("authorize");
}
});
it("follows the instance's ownership availability rather than the catalog order", () => {
// Google and GitHub publish a Paperclip-managed method, but it is
it.each(["gmail", "asana"])("follows %s ownership availability rather than the catalog order", (slug) => {
// These providers publish a Paperclip-managed method, but it is
// `platform_shared` and therefore unavailable until an operator configures
// the cloud connector. The state has to reflect what this instance can
// actually do, or the card promises one click and the screen shows a form.
const gmail = getConnectableAppDefinition("gmail")!;
expect(connectionSetupStateForApp(gmail)).toBe("register");
const app = getConnectableAppDefinition(slug)!;
expect(connectionSetupStateForApp(app)).toBe("register");
expect(
connectionSetupStateForApp({
...gmail,
ownershipAvailability: { ...gmail.ownershipAvailability, platform_shared: true },
...app,
ownershipAvailability: { ...app.ownershipAvailability, platform_shared: true },
}),
).toBe("authorize");
});
+1
View File
@@ -343,6 +343,7 @@ export {
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
export * from "./google-workspace-connectors.js";
export * from "./github-connectors.js";
export * from "./asana-connectors.js";
export {
BLOCKED_MCP_PROVIDERS,
SELF_SERVE_MCP_CANDIDATES,
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionPurpos
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
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; 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 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 type SelfServeMcpAuthMode =
+2
View File
@@ -182,6 +182,8 @@ export interface ToolConnection {
transportConfig: Record<string, unknown>;
config?: Record<string, unknown>;
credentialSecretRefs: ToolCredentialSecretRef[];
/** Saved client secret for the requesting user, or the shared connection. Never includes secret material. */
hasSavedOAuthClientSecret?: boolean;
credentialRefs?: McpConnectionCredentialRef[];
healthStatus: ToolConnectionHealthStatus;
/** Managed GitHub grant state; transient health failures do not require sign-in. */
@@ -6,6 +6,6 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
try{return new URL(value).protocol==="https:";}catch{return false;}
},{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(),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",message:"Config requirement references an unknown Line truncated
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 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)})});
+112 -67
View File
@@ -174,10 +174,10 @@ importers:
dependencies:
'@agentclientprotocol/claude-agent-acp':
specifier: ^0.73.0
version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))
version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))
'@anthropic-ai/sdk':
specifier: 0.121.0
version: 0.121.0(zod@4.4.3)
version: 0.121.0(zod@4.6.5)
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../../adapter-utils
@@ -499,7 +499,7 @@ importers:
dependencies:
'@agentclientprotocol/claude-agent-acp':
specifier: 0.73.0
version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))
version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))
'@agentclientprotocol/codex-acp':
specifier: 1.6.2
version: 1.6.2(patch_hash=rt7c5otjh4is5hljadvoq7qcem)
@@ -1089,8 +1089,8 @@ importers:
ui:
dependencies:
'@assistant-ui/react':
specifier: 0.15.16
version: 0.15.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
specifier: 0.15.21
version: 0.15.21(@opentelemetry/api@1.9.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
'@base-ui/react':
specifier: ^1.7.0
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -1179,8 +1179,8 @@ importers:
specifier: 0.48.0
version: 0.48.0(typescript@7.0.2)
lucide-react:
specifier: ^1.38.0
version: 1.38.0(react@19.2.8)
specifier: ^1.45.0
version: 1.45.0(react@19.2.8)
mermaid:
specifier: ^11.17.2
version: 11.17.2
@@ -1356,15 +1356,14 @@ packages:
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
engines: {node: ^22.13.0 || >=24.0.0}
'@assistant-ui/core@0.3.15':
resolution: {integrity: sha512-pCeVhMmzK4xFbahtRtvjlGXDxycsvl1plgiD0M5ZyABm+QxINGnBO5GEz1hqDHQVjKlCIJDNj15PlFSkuFnidA==}
'@assistant-ui/core@0.3.21':
resolution: {integrity: sha512-zD0u/N4RC57jcHWbsMezAkuUK6IbBzfjkl37o9TdRRDIseDs/iEpqede551kdGpgkuSVA7qlbMokHtY4upAbYw==}
peerDependencies:
'@assistant-ui/store': ^0.3.0
'@assistant-ui/tap': ^0.9.0
'@assistant-ui/store': ^0.3.15
'@assistant-ui/tap': ^0.9.19
'@types/react': '*'
assistant-cloud: ^0.1.31
assistant-cloud: ^0.2.1
react: ^19.2.8
zustand: ^5.0.11
peerDependenciesMeta:
'@types/react':
optional: true
@@ -1372,11 +1371,9 @@ packages:
optional: true
react:
optional: true
zustand:
optional: true
'@assistant-ui/react@0.15.16':
resolution: {integrity: sha512-+2BO6npVEXdViNWTyH3XddVXHo7SOcXliM8btrXGzH6PHuTtn3CYL7DCQ5ZXVRHB9kYPBbbMPURVu93jg9qIwg==}
'@assistant-ui/react@0.15.21':
resolution: {integrity: sha512-5VID3r2YpYFbx6LLfyOSWwRVf4WOJXYsfSN/C67sbCsJsqqBbxeQpn4MB9g73HcyriBkRwqvHY5XXCguyNqP6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1388,10 +1385,10 @@ packages:
'@types/react-dom':
optional: true
'@assistant-ui/store@0.3.10':
resolution: {integrity: sha512-QSFgodFt/daEjyaY09WdahNewsGPtAeY+DkSb2LM2rPN6634h13R1vJQvCtyWK5YsI5WXgzgbsctzgpIQLU0qw==}
'@assistant-ui/store@0.3.15':
resolution: {integrity: sha512-BF/SVDTe0GIa168lsbiBHAn4xt/LwB6bmMX4ZUycDAEI/yxoPHhV183GScFBb4xfWW7Dmj7fYFkHYDFtxMoFqg==}
peerDependencies:
'@assistant-ui/tap': ^0.9.12
'@assistant-ui/tap': ^0.9.19
'@types/react': '*'
react: ^19.2.8
peerDependenciesMeta:
@@ -1400,8 +1397,8 @@ packages:
react:
optional: true
'@assistant-ui/tap@0.9.14':
resolution: {integrity: sha512-L/ZyXLQb51/d+/5xlXaP1197PF94EO/Ji+/CBWTjuEbsU1+8dzXCHVNO9GCFQvyG02OiOVitfbRksipMVzdrnA==}
'@assistant-ui/tap@0.9.19':
resolution: {integrity: sha512-ggleeD5VPrgKhwYfa9T/cIcYsSlLJUSQr1fiippvPGVHp0ysaoQSoK+vll5DWVvc9PX9MtrYALpJPLL/6SG/sg==}
peerDependencies:
'@types/react': '*'
react: ^19.2.8
@@ -5165,6 +5162,9 @@ packages:
'@types/jsdom@30.0.0':
resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
@@ -5598,11 +5598,25 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
assistant-cloud@0.1.41:
resolution: {integrity: sha512-lrH9USOoNaAWAAbujeHa/PEiWoqNQHjIPIAeeA3y3GUXOdlmTjIyR/7GbEZBKbHhm1Lyt7aAYKvdxHFkhuEbJw==}
assistant-cloud@0.2.3:
resolution: {integrity: sha512-ufe78jldgmQ6XcKJUWLh0978u4WBN7N23IAzBV4hvyLnhSNUHEotlIr3/HKl3GjhJ1VK4+/aMmAtYPAq59DKfQ==}
peerDependencies:
'@opentelemetry/api': ^1.9.0
'@opentelemetry/exporter-trace-otlp-http': '>=0.200.0'
'@opentelemetry/sdk-trace-base': ^2.1.0
ai: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@opentelemetry/exporter-trace-otlp-http':
optional: true
'@opentelemetry/sdk-trace-base':
optional: true
ai:
optional: true
assistant-stream@0.3.39:
resolution: {integrity: sha512-Ad28saCwQxqaB69w4WNaS3uW5OW5BgTYnzTfbgFnz1CPXZjcWFsMrHhcJUpR4+lfYPTNal0oHEb47VUZH4Zz1g==}
assistant-stream@0.3.45:
resolution: {integrity: sha512-HBjy+VAqgxClbD73w6K96QC7mdzT5NcsOZgjaMAUoL4t3wsNzb3PUFoyoz2Yq2fkdckslDRc18snUtoKQMEy6w==}
peerDependencies:
ioredis: ^5.10.1 || ^6.0.0
redis: ^5.12.1
@@ -7267,8 +7281,8 @@ packages:
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
lucide-react@1.38.0:
resolution: {integrity: sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ==}
lucide-react@1.45.0:
resolution: {integrity: sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==}
peerDependencies:
react: ^19.2.8
@@ -8201,8 +8215,8 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-content-frame@0.0.27:
resolution: {integrity: sha512-IFUSMjElIp8q46wUU5HViHNEqoDWRlCzqqThQmhOLimuXPe/QC6Jr/AQ+BKxSYziQdNXUpapBq4Ir/t6dS0Ldg==}
safe-content-frame@0.0.31:
resolution: {integrity: sha512-q5oK9CnNBlsCaYSpMjTkveO9BiQU25PHeMykRyh59hlSkr0g0SSeL7P+dtTljKwNt0+m1qWD8982xUcN5jmPPQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
@@ -8979,6 +8993,9 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zod@4.6.5:
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
zustand@5.0.15:
resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==}
engines: {node: '>=12.20.0'}
@@ -9004,10 +9021,10 @@ snapshots:
'@adobe/css-tools@4.5.0': {}
'@agentclientprotocol/claude-agent-acp@0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))':
'@agentclientprotocol/claude-agent-acp@0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))':
dependencies:
'@agentclientprotocol/sdk': 1.4.0(zod@4.4.3)
'@anthropic-ai/claude-agent-sdk': 0.3.280(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
'@anthropic-ai/claude-agent-sdk': 0.3.280(@anthropic-ai/sdk@0.121.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))(zod@4.4.3)
zod: 4.4.3
transitivePeerDependencies:
- '@anthropic-ai/sdk'
@@ -9059,10 +9076,10 @@ snapshots:
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk@0.3.280(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
'@anthropic-ai/claude-agent-sdk@0.3.280(@anthropic-ai/sdk@0.121.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.121.0(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3)
'@anthropic-ai/sdk': 0.121.0(zod@4.6.5)
'@modelcontextprotocol/sdk': 1.30.0(zod@4.6.5)
zod: 4.4.3
optionalDependencies:
'@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.280
@@ -9074,12 +9091,12 @@ snapshots:
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.280
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.280
'@anthropic-ai/sdk@0.121.0(zod@4.4.3)':
'@anthropic-ai/sdk@0.121.0(zod@4.6.5)':
dependencies:
json-schema-to-ts: 3.1.1
standardwebhooks: 1.0.0
optionalDependencies:
zod: 4.4.3
zod: 4.6.5
'@asamuzakjp/css-color@6.0.7':
dependencies:
@@ -9096,60 +9113,55 @@ snapshots:
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
'@assistant-ui/core@0.3.15(@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41)(react@19.2.8)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)))':
'@assistant-ui/core@0.3.21(@assistant-ui/store@0.3.15(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.2.3(@opentelemetry/api@1.9.1))(react@19.2.8)':
dependencies:
'@assistant-ui/store': 0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)
'@assistant-ui/tap': 0.9.14(@types/react@19.2.18)(react@19.2.8)
assistant-stream: 0.3.39
'@assistant-ui/store': 0.3.15(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)
'@assistant-ui/tap': 0.9.19(@types/react@19.2.18)(react@19.2.8)
assistant-stream: 0.3.45
nanoid: 6.0.1
optionalDependencies:
'@types/react': 19.2.18
assistant-cloud: 0.1.41
assistant-cloud: 0.2.3(@opentelemetry/api@1.9.1)
react: 19.2.8
zustand: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
transitivePeerDependencies:
- ioredis
- redis
'@assistant-ui/react@0.15.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))':
'@assistant-ui/react@0.15.21(@opentelemetry/api@1.9.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))':
dependencies:
'@assistant-ui/core': 0.3.15(@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41)(react@19.2.8)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)))
'@assistant-ui/store': 0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)
'@assistant-ui/tap': 0.9.14(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.18)(react@19.2.8)
assistant-cloud: 0.1.41
assistant-stream: 0.3.39
'@assistant-ui/core': 0.3.21(@assistant-ui/store@0.3.15(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.2.3(@opentelemetry/api@1.9.1))(react@19.2.8)
'@assistant-ui/store': 0.3.15(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)
'@assistant-ui/tap': 0.9.19(@types/react@19.2.18)(react@19.2.8)
assistant-cloud: 0.2.3(@opentelemetry/api@1.9.1)
assistant-stream: 0.3.45
radix-ui: 1.6.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-textarea-autosize: 8.5.9(@types/react@19.2.18)(react@19.2.8)
safe-content-frame: 0.0.27
zod: 4.4.3
safe-content-frame: 0.0.31
zod: 4.6.5
zustand: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.5(@types/react@19.2.18)
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-http'
- '@opentelemetry/sdk-trace-base'
- ai
- immer
- ioredis
- redis
- use-sync-external-store
'@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)':
'@assistant-ui/store@0.3.15(@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@assistant-ui/tap': 0.9.14(@types/react@19.2.18)(react@19.2.8)
'@assistant-ui/tap': 0.9.19(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
react: 19.2.8
'@assistant-ui/tap@0.9.14(@types/react@19.2.18)(react@19.2.8)':
'@assistant-ui/tap@0.9.19(@types/react@19.2.18)(react@19.2.8)':
optionalDependencies:
'@types/react': 19.2.18
react: 19.2.8
@@ -11037,6 +11049,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@modelcontextprotocol/sdk@1.30.0(zod@4.6.5)':
dependencies:
'@hono/node-server': 2.1.0(hono@4.13.2)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.1.1
express: 5.2.1
express-rate-limit: 8.6.2(express@5.2.1)
hono: 4.13.2
jose: 6.2.8
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.6.5
zod-to-json-schema: 3.25.2(zod@4.6.5)
transitivePeerDependencies:
- supports-color
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
@@ -13033,6 +13067,8 @@ snapshots:
parse5: 8.0.1
undici-types: 8.10.0
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
@@ -13408,16 +13444,19 @@ snapshots:
assertion-error@2.0.1: {}
assistant-cloud@0.1.41:
assistant-cloud@0.2.3(@opentelemetry/api@1.9.1):
dependencies:
assistant-stream: 0.3.39
assistant-stream: 0.3.45
optionalDependencies:
'@opentelemetry/api': 1.9.1
transitivePeerDependencies:
- ioredis
- redis
assistant-stream@0.3.39:
assistant-stream@0.3.45:
dependencies:
'@standard-schema/spec': 1.1.0
'@types/json-schema': 7.0.15
nanoid: 6.0.1
secure-json-parse: 4.1.0
@@ -15021,7 +15060,7 @@ snapshots:
lru_map@0.4.1: {}
lucide-react@1.38.0(react@19.2.8):
lucide-react@1.45.0(react@19.2.8):
dependencies:
react: 19.2.8
@@ -16406,7 +16445,7 @@ snapshots:
safe-buffer@5.2.1: {}
safe-content-frame@0.0.27: {}
safe-content-frame@0.0.31: {}
safe-stable-stringify@2.5.0: {}
@@ -17188,12 +17227,18 @@ snapshots:
dependencies:
zod: 4.4.3
zod-to-json-schema@3.25.2(zod@4.6.5):
dependencies:
zod: 4.6.5
zod@3.25.76: {}
zod@4.1.11: {}
zod@4.4.3: {}
zod@4.6.5: {}
zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)):
optionalDependencies:
'@types/react': 19.2.18
@@ -102,7 +102,7 @@ process.exit(0);
return baseCwd;
}
function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, existingWorktree } = {}) {
function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, existingWorktree, env = {} } = {}) {
const worktreeCwd = existingWorktree ?? makeTempDir("paperclip-provision-worktree-");
setupWorktree?.(worktreeCwd);
const worktreesHome = makeTempDir("paperclip-provision-home-");
@@ -121,14 +121,16 @@ function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, exist
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
...(typeof env === "function" ? env(paperclipHome) : env),
},
});
return { result, worktreeCwd, worktreesHome, paperclipHome };
}
function runRuntimeProvision(baseCwd, worktreeCwd) {
function runRuntimeProvision(baseCwd, worktreeCwd, { setupInstance } = {}) {
const worktreesHome = makeTempDir("paperclip-provision-runtime-home-");
const paperclipHome = makeInstanceHome();
setupInstance?.(paperclipHome);
return spawnSync("bash", [runtimeScript], {
cwd: worktreeCwd,
encoding: "utf8",
@@ -183,22 +185,122 @@ test("uses the base CLI when its import graph boots", () => {
);
});
test("explains unavailable instance source config without creating target state", () => {
for (const defaultConfigInEnv of [false, true]) {
test(`prepares a plain checkout without a default instance config (image default env: ${defaultConfigInEnv})`, () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const { result, worktreeCwd } = runProvision(baseCwd, {
setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
env: (home) => defaultConfigInEnv ? { PAPERCLIP_CONFIG: path.join(home, "instances", "default", "config.json") } : {},
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stderr, /preparing checkout dependencies without a seeded development instance/);
for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) {
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", file)), false);
}
assert.equal(fs.realpathSync(path.join(worktreeCwd, "cli", "node_modules")), path.join(baseCwd, "cli", "node_modules"));
assert.deepEqual(readCliInvocations(baseCwd), []);
});
}
test("rejects a missing custom seed source", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const { result, worktreeCwd } = runProvision(baseCwd, {
setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
env: { PAPERCLIP_CONFIG: path.join(baseCwd, "missing", "config.json") },
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /config is unavailable \(control-plane instance\)/);
assert.match(result.stderr, /For a seeded development instance, configure a canonical config/);
assert.match(result.stderr, /Only for a checkout-only worktree/);
assert.match(result.stderr, /workspaceStrategy\.provisionCommand to "true"/);
assert.match(result.stderr, /does not prepare a development runtime/);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip")), false);
assert.deepEqual(readCliInvocations(baseCwd), []);
});
test("initializes a previously plain checkout once a registered seed source is available", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const first = runProvision(baseCwd, {
setupInstance: (home) => fs.rmSync(path.join(home, "instances"), { recursive: true }),
});
assert.equal(first.result.status, 0, first.result.stderr);
assert.deepEqual(readCliInvocations(baseCwd), []);
const second = runProvision(baseCwd, { existingWorktree: first.worktreeCwd });
assert.equal(second.result.status, 0, second.result.stderr);
assert.equal(readWorktreeConfig(first.worktreeCwd).$meta.source, "fake-cli");
assert.equal(JSON.parse(fs.readFileSync(path.join(first.worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, "pending");
});
test("a plain checkout without a source is not ready for a seeded runtime", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
fs.mkdirSync(path.join(baseCwd, "scripts"));
fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh"));
const setupInstance = (home) => fs.rmSync(path.join(home, "instances"), { recursive: true });
const { result, worktreeCwd } = runProvision(baseCwd, { setupInstance });
assert.equal(result.status, 0, result.stderr);
const runtime = runRuntimeProvision(baseCwd, worktreeCwd, { setupInstance });
assert.notEqual(runtime.status, 0);
assert.match(runtime.stderr, /Worktree config still does not exist after built-in provisioning/);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json")), false);
assert.deepEqual(readCliInvocations(baseCwd), []);
});
for (const relativePath of ["instances/default/config.json", "instances/default"]) {
test(`rejects a dangling implicit source symlink at ${relativePath}`, () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const { result, worktreeCwd } = runProvision(baseCwd, {
setupInstance(home) {
const sourcePath = path.join(home, relativePath);
fs.rmSync(sourcePath, { recursive: true });
fs.symlinkSync(path.join(home, "missing"), sourcePath);
},
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /canonical/);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip")), false);
assert.deepEqual(readCliInvocations(baseCwd), []);
});
}
for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) {
test(`does not downgrade an existing development instance with ${file} when its source disappears`, () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const { result, worktreeCwd } = runProvision(baseCwd, {
setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
setupWorktree(root) {
fs.mkdirSync(path.join(root, ".paperclip"));
fs.writeFileSync(path.join(root, ".paperclip", file), "retained state\n");
},
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /config is unavailable/);
assert.equal(fs.readFileSync(path.join(worktreeCwd, ".paperclip", file), "utf8"), "retained state\n");
assert.deepEqual(readCliInvocations(baseCwd), []);
});
}
for (const installExit of [0, 42]) {
test(`plain checkout dependency provisioning preserves install exit ${installExit}`, () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const bin = makeTempDir("paperclip-checkout-pnpm-");
fs.writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh\nprintf '%s\\n' "$*" >> pnpm-calls\nmkdir -p node_modules cli/node_modules\nexit ${installExit}\n`, { mode: 0o700 });
const { result, worktreeCwd } = runProvision(baseCwd, {
pathPrefix: bin,
setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
setupWorktree(root) {
fs.writeFileSync(path.join(root, "package.json"), "{}\n");
fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
},
});
assert.equal(result.status, installExit, result.stderr);
assert.match(fs.readFileSync(path.join(worktreeCwd, "pnpm-calls"), "utf8"), /^install --prod=false --frozen-lockfile/);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "pnpm-install-fingerprint")), installExit === 0);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "config.json")), false);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json")), false);
assert.deepEqual(readCliInvocations(baseCwd), []);
});
}
test("rejects a dangling base workspace config symlink instead of falling back", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
+25
View File
@@ -1095,6 +1095,31 @@ const apiKeyMethodFor = (
);
};
const specialMethodsFor = (entry) => {
if (entry.slug === "asana") return [
oauthMethodFor(entry, "managed", entry.serverUrl, {
label: "Sign in with Asana",
ownershipModes: ["platform_shared"],
oauthStrategy: "paperclip_cloud_connector",
connectorProfile: "asana.mcp",
grantKinds: ["user", "agent"],
defaults: { serverUrl: entry.serverUrl, scopesHint: ["default"] },
guidanceMd: "Sign in to Asana with Paperclip. Asana gives this connection access to the workspaces available to your account.",
whenToUse: "Connect your Asana account with Paperclip's app.",
warnings: [],
}),
{
...customerOAuthMethodFor(entry),
defaults: {
serverUrl: entry.serverUrl,
discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2",
scopesHint: ["default"],
},
oauthClientSecretRequired: true,
guidanceMd: "Create an MCP app in Asana, then add the callback URL below under OAuth. Under Manage distribution, select your workspace and save. API apps do not work with Asana MCP.",
consoleLinks: { register: "https://app.asana.com/0/my-apps", docs: entry.docsUrl },
warnings: [],
},
];
if (entry.slug === "mem0" || entry.slug === "honcho") return [
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.`,
+79 -50
View File
@@ -5,6 +5,7 @@ base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is requir
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
default_source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
paperclip_dir="$worktree_cwd/.paperclip"
worktree_config_path="$paperclip_dir/config.json"
worktree_env_path="$paperclip_dir/.env"
@@ -53,26 +54,52 @@ if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
# A base workspace that is a plain checkout carries no instance config of its own.
# Fall back to the control plane's own registered instance config, which is process
# state this workspace cannot rewrite.
source_config_path="${PAPERCLIP_CONFIG:-$paperclip_home/instances/$paperclip_instance_id/config.json}"
source_config_path="${PAPERCLIP_CONFIG:-$default_source_config_path}"
source_config_origin="control-plane instance"
fi
if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then
if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
echo "Registered Paperclip seed source config is unavailable ($source_config_origin): $source_config_path" >&2
echo "For a seeded development instance, configure a canonical config for that registered source before retrying." >&2
echo 'Only for a checkout-only worktree, explicitly set workspaceStrategy.provisionCommand to "true". This skips setup; it does not prepare a development runtime.' >&2
else
echo "Registered Paperclip seed source config is not a canonical file ($source_config_origin): $source_config_path" >&2
echo "Repair the registered source path; symlinks and non-regular files are not accepted." >&2
# Environment-configured servers need no local instance config. A new plain
# checkout can still prepare dependencies without inventing a seed source.
# The Docker image sets PAPERCLIP_CONFIG to this default even without a file.
# Custom sources and existing development instances must still fail closed.
if [[ "$source_config_path" == "$default_source_config_path" && ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
source_required=0
for target_state in "$worktree_config_path" "$worktree_env_path" "$seed_manifest_path" "$seed_pending_marker_path" "$seed_complete_marker_path"; do
if [[ -e "$target_state" || -L "$target_state" ]]; then
source_required=1
fi
done
if [[ "$source_required" -eq 0 ]]; then
# Do not mistake a broken or aliased parent path for an absent instance.
source_parent="$(dirname "$source_config_path")"
while [[ ! -e "$source_parent" && ! -L "$source_parent" ]]; do
source_parent="$(dirname "$source_parent")"
done
if [[ ! -d "$source_parent" || -L "$source_parent" || "$(cd "$source_parent" && pwd -P)" != "$source_parent" ]]; then
echo "Registered Paperclip seed source config has a non-canonical parent: $source_config_path" >&2
exit 1
fi
echo "No local Paperclip seed source config; preparing checkout dependencies without a seeded development instance." >&2
source_config_path=""
fi
fi
exit 1
fi
canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)"
if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then
echo "Registered Paperclip seed source config uses a symlink alias: $source_config_path" >&2
exit 1
if [[ -n "$source_config_path" ]]; then
if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then
if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
echo "Registered Paperclip seed source config is unavailable ($source_config_origin): $source_config_path" >&2
echo "For a seeded development instance, configure a canonical config for that registered source before retrying." >&2
else
echo "Registered Paperclip seed source config is not a canonical file ($source_config_origin): $source_config_path" >&2
echo "Repair the registered source path; symlinks and non-regular files are not accepted." >&2
fi
exit 1
fi
canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)"
if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then
echo "Registered Paperclip seed source config uses a symlink alias: $source_config_path" >&2
exit 1
fi
source_env_path="$(dirname "$source_config_path")/.env"
fi
source_env_path="$(dirname "$source_config_path")/.env"
mkdir -p "$paperclip_dir"
@@ -619,44 +646,46 @@ main().catch((error) => {
EOF
}
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
else
if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
fi
if paperclipai_command_available; then
if run_isolated_worktree_init; then
:
else
init_exit_code=$?
if [[ "$init_exit_code" -eq 127 ]]; then
# Every CLI candidate was unusable (e.g. an unhealthy base install that
# the repair could not fix); degrade instead of stranding the run.
echo "No usable paperclipai CLI found; writing isolated fallback config without DB seeding." >&2
write_fallback_worktree_config
else
# A CLI that ran and failed signals a real problem; do not paper over
# it with an unseeded fallback config.
echo "paperclipai worktree init failed (exit $init_exit_code); failing provisioning instead of writing an unseeded fallback config." >&2
exit "$init_exit_code"
fi
fi
if [[ -n "$source_config_path" ]]; then
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
else
echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2
write_fallback_worktree_config
if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
fi
if paperclipai_command_available; then
if run_isolated_worktree_init; then
:
else
init_exit_code=$?
if [[ "$init_exit_code" -eq 127 ]]; then
# Every CLI candidate was unusable (e.g. an unhealthy base install that
# the repair could not fix); degrade instead of stranding the run.
echo "No usable paperclipai CLI found; writing isolated fallback config without DB seeding." >&2
write_fallback_worktree_config
else
# A CLI that ran and failed signals a real problem; do not paper over
# it with an unseeded fallback config.
echo "paperclipai worktree init failed (exit $init_exit_code); failing provisioning instead of writing an unseeded fallback config." >&2
exit "$init_exit_code"
fi
fi
else
echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2
write_fallback_worktree_config
fi
created_worktree_config=1
fi
created_worktree_config=1
fi
# The target config can predate a deployment-mode change on the registered
# source, and older/fallback CLI writers may default this field independently.
# Reconcile it after either create or reuse so the final guest config always
# carries the source's deployment/auth contract without replacing its database.
reconcile_worktree_deployment_mode
# The target config can predate a deployment-mode change on the registered
# source, and older/fallback CLI writers may default this field independently.
# Reconcile it after either create or reuse so the final guest config always
# carries the source's deployment/auth contract without replacing its database.
reconcile_worktree_deployment_mode
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
write_seed_pending_manifest
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
write_seed_pending_manifest
fi
fi
list_base_node_modules_paths() {
+21 -10
View File
@@ -106,21 +106,32 @@ describe("Docker Rust dependency cache", () => {
});
describe("Cloud remote provider pack", () => {
it("ships a build-owned pack without provisioning Grok on the controller", () => {
const pack = stageBody(dockerfile, "cloud-provider-pack");
const cloud = stageBody(dockerfile, "cloud");
describe("Standard image remote provider pack", () => {
it("ships the build-owned pack in the production base used by downstream images", () => {
const pack = stageBody(dockerfile, "runner-provider-pack");
const production = stageBody(dockerfile, "production");
expect(pack).toContain("FROM build AS runner-provider-pack");
expect(pack).toContain('PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}"');
expect(pack).toContain("build-provider-pack.mjs /provider-pack");
expect(pack).toContain('if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then');
expect(pack).toContain("mkdir -p /provider-pack");
expect(pack).toContain("Skipping remote provider pack");
expect(cloud).toContain("--from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack");
expect(cloud).not.toContain("--chown=node:node --from=cloud-provider-pack");
expect(cloud).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack");
expect(cloud).toContain("gosu 65534:65534 node");
expect(cloud).toContain("Object.values(manifest.payload.artifacts)");
expect(cloud).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack");
expect(production).toContain("--from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack");
expect(production).not.toContain("--chown=node:node --from=runner-provider-pack");
expect(production).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack");
expect(production).toContain("gosu 65534:65534 node");
expect(production).toContain("Object.values(manifest.payload.artifacts)");
expect(production).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack");
expect(workflow).toMatch(/^\s*target: production$/m);
expect(dockerfile).not.toContain("provision-grok.mjs");
});
it("lets explicit cloud preview builds inherit the same pack and configuration", () => {
const cloud = stageBody(dockerfile, "cloud");
expect(cloud).toContain("FROM production AS cloud");
expect(cloud).not.toContain("/opt/paperclip-runner/provider-pack");
expect(cloud).not.toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH");
expect(dockerfile.match(/build-provider-pack\.mjs \/provider-pack/g)).toHaveLength(1);
expect(previewWorkflow).toMatch(/^\s*target: cloud$/m);
});
});
+179 -17
View File
@@ -10311,6 +10311,7 @@ describeEmbeddedPostgres("tool access service", () => {
])(
"starts and completes OAuth with the same redirect URI at %s",
async (origin) => {
const callbackOrigin = origin.replace("http://127.0.0.1:", "http://localhost:");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv(
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
@@ -10349,7 +10350,7 @@ describeEmbeddedPostgres("tool access service", () => {
/^[A-Za-z0-9_-]{43}$/,
);
expect(startUrl.searchParams.get("redirect_uri")).toBe(
`${origin}/api/tools/oauth/callback`,
`${callbackOrigin}/api/tools/oauth/callback`,
);
const state = startUrl.searchParams.get("state");
expect(state).toBeTruthy();
@@ -10375,7 +10376,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(body.get("client_secret")).toBe("slack-client-secret");
expect(body.get("code_verifier")).toBeTruthy();
expect(body.get("redirect_uri")).toBe(
`${origin}/api/tools/oauth/callback`,
`${callbackOrigin}/api/tools/oauth/callback`,
);
return {
ok: true,
@@ -10417,7 +10418,7 @@ describeEmbeddedPostgres("tool access service", () => {
const callbackRes = await request(app)
.get("/api/tools/oauth/callback")
.set("Host", new URL(origin).host)
.set("Host", new URL(callbackOrigin).host)
.query({ state, code: "oauth-code" });
expect(callbackRes.status).toBe(200);
@@ -10462,7 +10463,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(redirectState).toBeTruthy();
const redirectCallbackRes = await request(app)
.get("/api/tools/oauth/callback")
.set("Host", new URL(origin).host)
.set("Host", new URL(callbackOrigin).host)
.set("Accept", "text/html")
.query({ state: redirectState, code: "oauth-code" });
@@ -10497,6 +10498,7 @@ describeEmbeddedPostgres("tool access service", () => {
const connectRes = await request(app)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.set("Host", "127.0.0.1:3200")
.set("Origin", "http://127.0.0.1:3200")
.send({ galleryKey: "slack", name: "Loopback Slack workspace" });
expect(connectRes.status).toBe(201);
@@ -11616,11 +11618,89 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana",
oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" },
});
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
await db.update(toolConnections).set({ config: {
...connection.config,
oauth: { ...connection.config.oauth as Record<string, unknown>,
issuer: "https://mcp.asana.com", authorizationUrl: "https://mcp.asana.com/authorize",
tokenUrl: "https://mcp.asana.com/token", resource: "https://mcp.asana.com",
clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com",
clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback" },
} }).where(eq(toolConnections.id, connected.connectionId));
const calls: string[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
calls.push(String(url));
if (String(url) === "https://mcp.asana.com/.well-known/oauth-protected-resource/v2") {
return mcpHttpResponse({ resource: "https://mcp.asana.com/v2/mcp",
authorization_servers: ["https://app.asana.com"], scopes_supported: ["default"] });
}
if (String(url) === "https://app.asana.com/.well-known/oauth-authorization-server") {
return mcpHttpResponse({ issuer: "https://app.asana.com",
authorization_endpoint: "https://app.asana.com/-/oauth_authorize",
token_endpoint: "https://app.asana.com/-/oauth_token",
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
code_challenge_methods_supported: ["S256"] });
}
throw new Error(`unexpected fetch ${url}`);
});
const started = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: "http://localhost:3200/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const authorization = new URL(started.authorizationUrl);
expect(authorization.origin + authorization.pathname).toBe("https://app.asana.com/-/oauth_authorize");
expect(authorization.searchParams.get("resource")).toBe("https://mcp.asana.com/v2/mcp");
expect(authorization.searchParams.get("scope")).toBe("default");
expect(authorization.searchParams.get("client_id")).toBe("asana-client");
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
expect(calls).toEqual([
"https://mcp.asana.com/.well-known/oauth-protected-resource/v2",
"https://app.asana.com/.well-known/oauth-authorization-server",
]);
const migrated = await service.getConnection(connected.connectionId, company.id);
expect(migrated.config?.oauth).toMatchObject({
clientIssuer: "https://app.asana.com", clientResource: "https://mcp.asana.com/v2/mcp",
});
expect(migrated.credentialSecretRefs).toEqual(connection.credentialSecretRefs);
for (const invalidBinding of [
{ clientIssuer: "https://other.example" },
{ clientResource: "https://other.example/mcp" },
{ clientCompanyId: randomUUID() },
{ clientRedirectUri: "https://other.example/api/tools/oauth/callback" },
]) {
await db.update(toolConnections).set({ config: { ...connection.config, oauth: {
...connection.config.oauth as Record<string, unknown>,
clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com",
clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback", ...invalidBinding,
} } }).where(eq(toolConnections.id, connection.id));
await expect(service.startOAuth(company.id, connection.id, {
redirectUri: "http://localhost:3200/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({ status: 422, details: { code: "oauth_manual_client_rebinding_required" } });
}
const missingSecret = await service.connectGalleryApp(company.id, {
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth",
oauthClient: { clientId: "another-client" },
});
await expect(service.startOAuth(company.id, missingSecret.connectionId, {
redirectUri: "http://localhost:3200/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({ status: 422, message: expect.stringContaining("requires an OAuth client secret") });
});
it("stores a curated customer-owned OAuth client without exposing its secret", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "asana",
connectionMethodKey: "mcp-own-oauth",
name: "Asana own app",
oauthClient: {
clientId: "asana-customer-client",
@@ -11653,6 +11733,7 @@ describeEmbeddedPostgres("tool access service", () => {
const resumed = await service.connectGalleryApp(company.id, {
galleryKey: "asana",
connectionMethodKey: "mcp-own-oauth",
name: "Asana own app",
resumeConnectionId: connected.connectionId,
oauthClient: {
@@ -11668,11 +11749,101 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it.each([false, true])("finishes a second Asana account with a colliding profile name (installed: %s)", async (installed) => {
const company = await createCompany(db);
const userId = "asana-owner";
await grantBoardUser(db, company.id, userId, [], "owner");
const agent = await createAgent(db, company.id);
const actor = { actorType: "user" as const, actorId: userId };
const credentials = {
v: 1 as const, accessToken: "asana-access", refreshToken: "asana-refresh", tokenType: "Bearer",
accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
refreshTokenExpiresAt: null, scopes: ["default"], subject: userId, companyId: company.id,
instanceId: "test-instance", environment: "staging" as const,
provider: "asana" as const, profile: "asana.mcp" as const,
};
const connector: PaperclipCloudConnector = {
getCapabilities: vi.fn(async () => ["asana.mcp"]),
startAuthorization: vi.fn(async ({ returnState }) => ({
authorizationUrl: `https://app.asana.com/-/oauth_authorize?state=${encodeURIComponent(returnState)}`,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
})),
claim: vi.fn(async () => credentials), refresh: vi.fn(async () => credentials),
revoke: vi.fn(async () => undefined),
};
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
const custom = await service.connectGalleryApp(company.id, {
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana", grantKind: "user",
oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" },
}, actor);
const first = await service.finishGalleryAppConnection(company.id, custom.connectionId, {
enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [] },
}, actor);
await service.updateConnection(custom.connectionId, { name: "Asana custom app" });
const managed = await service.connectGalleryApp(company.id, {
galleryKey: "asana", connectionMethodKey: "managed", name: "Asana", grantKind: "user",
applicationId: custom.application.id,
}, actor);
expect(managed.connection.name).toBe("Asana");
if (installed) await service.putConnectionInstalls(managed.connectionId, {
installs: [{ targetType: "agent", targetId: agent.id }],
}, actor);
const started = await service.startOAuth(company.id, managed.connectionId, {
redirectUri: "https://tenant.example/api/tools/oauth/cloud-connector/callback", actor,
});
mockToolsList([{ name: "get_me", annotations: { readOnlyHint: true } }]);
const completed = await service.completePaperclipCloudConnectorCallback({
state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "asana-claim", actor,
});
expect(completed.connection).toMatchObject({ status: "active", enabled: true });
expect(completed.catalog).toEqual([expect.objectContaining({ toolName: "get_me", status: "active" })]);
const repeat = await service.finishGalleryAppConnection(company.id, managed.connectionId, {
enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [],
access: { agentIds: installed ? [agent.id] : [] },
}, actor);
expect(repeat.profile.name).not.toBe(first.profile.name);
expect(repeat.profileBindings.map((binding) => binding.targetId)).toEqual(installed ? [agent.id] : []);
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).toHaveLength(2);
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.id, first.profile.id)))
.toEqual([expect.objectContaining({ name: first.profile.name })]);
});
it("resumes personal Asana client credentials with database timestamp precision", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const actor = { actorType: "user" as const, actorId: "asana-owner" };
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user",
oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" },
}, actor);
const before = await service.listConnectionGrants(connected.connectionId, company.id);
const grant = before.grants[0]!;
await db.update(connectionGrants).set({ updatedAt: sql`'2026-01-01 12:00:00.123456'::timestamp` })
.where(eq(connectionGrants.id, grant.id));
await service.connectGalleryApp(company.id, {
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user",
resumeConnectionId: connected.connectionId, oauthClient: { clientId: "asana-client" },
}, actor);
const after = await service.listConnectionGrants(connected.connectionId, company.id);
expect(after.grants).toHaveLength(1);
expect(after.grants[0]!.credentialSecretRefs).toEqual(grant.credentialSecretRefs);
expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]);
expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(true);
expect((await service.listConnections(company.id, actor.actorId))[0].hasSavedOAuthClientSecret).toBe(true);
expect((await service.getConnection(connected.connectionId, company.id, "another-user")).hasSavedOAuthClientSecret).toBe(false);
expect((await service.getConnection(connected.connectionId, company.id)).hasSavedOAuthClientSecret).toBe(false);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id));
expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false);
await db.update(connectionGrants).set({ status: "active", credentialSecretRefs: [] }).where(eq(connectionGrants.id, grant.id));
expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false);
});
it("does not retain a customer OAuth secret when the client id changes", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "asana",
connectionMethodKey: "mcp-own-oauth",
name: "Asana own app",
oauthClient: {
clientId: "asana-first-client",
@@ -11682,6 +11853,7 @@ describeEmbeddedPostgres("tool access service", () => {
const resumed = await service.connectGalleryApp(company.id, {
galleryKey: "asana",
connectionMethodKey: "mcp-own-oauth",
name: "Asana own app",
resumeConnectionId: connected.connectionId,
oauthClient: {
@@ -15478,18 +15650,8 @@ describeEmbeddedPostgres("tool access service", () => {
),
);
await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `conflict-${randomUUID()}`,
name: "Conflicting app profile",
status: "active",
defaultAction: "deny",
});
await db
.update(toolConnections)
.set({ name: "Conflicting app profile", updatedAt: new Date() })
.where(eq(toolConnections.id, connect.connectionId));
// An absent actor trips the binding's foreign key after profile state is
// replaced. Profile-name collisions are now resolved during finalization.
await expect(
service.finishGalleryAppConnection(
company.id,
@@ -15499,7 +15661,7 @@ describeEmbeddedPostgres("tool access service", () => {
askFirstCatalogEntryIds: [updateEntry.id],
access: { agentIds: [agent.id] },
},
{ actorType: "user", actorId: "board" },
{ actorType: "agent", actorId: randomUUID() },
),
).rejects.toThrow();
@@ -920,6 +920,43 @@ describe("realizeExecutionWorkspace", () => {
}
});
it.each([false, true])("realizes a plain Paperclip checkout without a local seed instance (image default env: %s)", async (defaultConfigInEnv) => {
const repoRoot = await createTempRepo();
const paperclipHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-env-only-home-")));
const previousEnv = {
PAPERCLIP_HOME: process.env.PAPERCLIP_HOME,
PAPERCLIP_CONFIG: process.env.PAPERCLIP_CONFIG,
PAPERCLIP_INSTANCE_ID: process.env.PAPERCLIP_INSTANCE_ID,
};
try {
await fs.mkdir(path.join(repoRoot, "scripts"));
await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add Paperclip worktree provisioner"]);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "default";
if (defaultConfigInEnv) process.env.PAPERCLIP_CONFIG = path.join(paperclipHome, "instances", "default", "config.json");
else delete process.env.PAPERCLIP_CONFIG;
const workspace = await realizeWorktreeForTest(repoRoot, "HEAD");
expect(workspace.strategy).toBe("git_worktree");
expect(workspace.created).toBe(true);
expect(await readGit(workspace.cwd, ["branch", "--show-current"])).toBe(workspace.branchName);
for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) {
expect(existsSync(path.join(workspace.cwd, ".paperclip", file))).toBe(false);
}
expect(await fs.readdir(paperclipHome)).toEqual([]);
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await fs.rm(repoRoot, { recursive: true, force: true });
await fs.rm(paperclipHome, { recursive: true, force: true });
}
});
it("defaults the repo-provided worktree provisioner for git worktree strategies", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
+1 -1
View File
@@ -393,7 +393,7 @@ export function toolAccessRoutes(
: null;
if (
parsed.host.toLowerCase() === normalizedRoutedHost
&& (parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)))
&& parsed.protocol === "https:"
) {
return parsed.origin;
}
@@ -262,6 +262,27 @@ describe("Paperclip Cloud connector", () => {
});
});
it.each([
["https://app.asana.com/-/oauth_authorize", true],
["https://mcp.asana.com/authorize", false],
["https://app.asana.com/other", false],
])("validates Asana's exact v2 authorization endpoint %s", async (authorizationUrl, accepted) => {
const keys = config();
const connector = createPaperclipCloudConnector({ config: keys.config,
request: vi.fn(async (_url, init) => {
const body = JSON.parse(String(init?.body));
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
expect(claims).toMatchObject({ prv: "asana", prf: "asana.mcp", scp: ["default"] });
return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=test",
authorizationUrl, expiresAt: "2099-01-01T00:00:00Z" });
}) as typeof fetch,
});
const result = connector.startAuthorization({ subject, companyId, profile: "asana.mcp",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "state" });
if (accepted) await expect(result).resolves.toMatchObject({ authorizationUrl });
else await expect(result).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
});
it("accepts the fixed Google authorization endpoint for Google profiles", async () => {
const keys = config();
const connector = createPaperclipCloudConnector({
@@ -1,3 +1,4 @@
import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
import {
createDecipheriv,
createHash,
@@ -33,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production";
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack";
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId;
export type PaperclipCloudConnectorProvider = "google" | "github";
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId;
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana";
export type PaperclipCloudConnectorConfig = {
baseUrl: string;
@@ -754,6 +755,9 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId):
provider: PaperclipCloudConnectorProvider;
scopes: readonly string[];
} {
if (isAsanaConnectorProfileId(profile)) {
return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES };
}
if (isGitHubConnectorProfileId(profile)) {
return { provider: "github", scopes: GITHUB_CONNECTOR_PROFILES[profile].scopes };
}
@@ -764,6 +768,9 @@ function isExpectedProviderAuthorizationUrl(
profile: PaperclipCloudConnectorProfileId,
url: URL,
): boolean {
if (isAsanaConnectorProfileId(profile)) {
return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize";
}
if (isGitHubConnectorProfileId(profile)) {
return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize";
}
@@ -771,7 +778,7 @@ function isExpectedProviderAuthorizationUrl(
}
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value);
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
}
async function sha256Base64Url(value: string): Promise<string> {
+88 -10
View File
@@ -1,4 +1,5 @@
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";
import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js";
import { browserUseService } from "./browser-use.js";
@@ -2492,13 +2493,16 @@ export function isGoogleWorkspaceToolAllowed(
}
type ManagedConnectorProfileId =
GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId;
GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId;
function managedConnectorProfile(value: string | undefined): {
id: ManagedConnectorProfileId;
provider: "google" | "github";
provider: "google" | "github" | "asana";
scopes: readonly string[];
} | null {
if (value && isAsanaConnectorProfileId(value)) {
return { id: value, provider: "asana", scopes: ASANA_CONNECTOR_SCOPES };
}
if (value && isGoogleWorkspaceConnectorProfileId(value)) {
return {
id: value,
@@ -8403,6 +8407,27 @@ export function toolAccessService(
return oauthClientConfig(provider);
}
async function annotateSavedOAuthClientSecret(connections: ToolConnection[], viewerUserId?: string) {
const personal = connections.filter((connection) => connection.authKind === "oauth" && connection.credentialPolicy === "per_user");
const grants = viewerUserId && personal.length > 0
? await db.select({ connectionId: connectionGrants.connectionId, refs: connectionGrants.credentialSecretRefs })
.from(connectionGrants)
.where(and(
inArray(connectionGrants.companyId, [...new Set(personal.map((connection) => connection.companyId))]),
inArray(connectionGrants.connectionId, personal.map((connection) => connection.id)),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, viewerUserId),
eq(connectionGrants.status, "active"),
))
: [];
const savedPersonal = new Set(grants.filter((grant) => grant.refs.some((ref) => ref.configPath === "oauth.client_secret")).map((grant) => grant.connectionId));
for (const connection of connections) {
connection.hasSavedOAuthClientSecret = connection.credentialPolicy === "per_user"
? savedPersonal.has(connection.id)
: connection.credentialSecretRefs.some((ref) => ref.configPath === "oauth.client_secret");
}
}
async function oauthClientForConnection(
connection: typeof toolConnections.$inferSelect,
provider: string,
@@ -9031,6 +9056,17 @@ export function toolAccessService(
connection.ownership !== "dcr" &&
typeof storedOAuth.clientId === "string" &&
storedOAuth.clientId.trim().length > 0;
// A reviewed protected-resource document is authoritative for this method.
// Some hosts still serve metadata for a retired MCP server at the root.
// Resolve it before cached endpoints, including on existing draft reconnects.
if (!smokeLabEndpoints && galleryMethod?.defaults?.discoveryUrl) {
const endpoints = await endpointsFromMetadataUrl(
connection, galleryMethod.defaults.discoveryUrl, [], originOf(redirectUri),
);
if (!endpoints) throw unprocessable("OAuth provider metadata could not be loaded");
assertNotSmokeLabOAuthEndpoints(connection, endpoints);
return endpoints;
}
const hasCompleteGalleryEndpointHints = Boolean(
galleryMethod?.defaults?.authorizationEndpoint &&
galleryMethod.defaults.tokenEndpoint &&
@@ -9638,13 +9674,34 @@ export function toolAccessService(
* reusing them across a moved binding would let a re-pointed endpoint borrow
* another server's registration.
*/
function reviewedOAuthClientBinding(connection: typeof toolConnections.$inferSelect, endpoints: OAuthProviderEndpoints) {
const oauth = oauthConfig(connection);
// Only repair the known Asana v1 discovery mistake. Retain company and
// callback checks, and never carry a secret to an arbitrary new issuer.
if (connection.config.sourceTemplateKey === "asana"
&& connection.config.connectionMethodKey === "mcp-own-oauth"
&& connection.config.url === "https://mcp.asana.com/v2/mcp"
&& oauth.clientRegistrationSource === "manual"
&& connection.ownership !== "dcr"
&& endpoints.issuer === "https://app.asana.com"
&& endpoints.authorizationUrl === "https://app.asana.com/-/oauth_authorize"
&& endpoints.tokenUrl === "https://app.asana.com/-/oauth_token"
&& endpoints.resource === "https://mcp.asana.com/v2/mcp"
&& [undefined, null, "https://mcp.asana.com", "https://app.asana.com"].includes(oauth.clientIssuer as string | null | undefined)
&& [undefined, null, "https://mcp.asana.com", "https://mcp.asana.com/v2/mcp"].includes(oauth.clientResource as string | null | undefined)
&& (oauth.clientIssuer === "https://mcp.asana.com" || oauth.clientResource === "https://mcp.asana.com")) {
return { ...oauth, clientIssuer: endpoints.issuer, clientResource: endpoints.resource };
}
return oauth;
}
function oauthClientBindingMatches(
connection: typeof toolConnections.$inferSelect,
endpoints: OAuthProviderEndpoints,
redirectUri: string,
clientIdMetadataDocumentUrl: string | null,
): boolean {
const oauth = oauthConfig(connection);
const oauth = reviewedOAuthClientBinding(connection, endpoints);
if (typeof oauth.clientId !== "string" || !oauth.clientId.trim())
return false;
const source =
@@ -9709,7 +9766,8 @@ export function toolAccessService(
endpoints: OAuthProviderEndpoints,
redirectUri: string,
): Promise<typeof toolConnections.$inferSelect> {
const oauth = oauthConfig(connection);
const originalOAuth = oauthConfig(connection);
const oauth = reviewedOAuthClientBinding(connection, endpoints);
const nextBinding = {
clientRedirectUri: redirectUri,
clientIssuer:
@@ -9726,7 +9784,7 @@ export function toolAccessService(
: connection.companyId,
};
const unchanged = Object.entries(nextBinding).every(
([key, value]) => oauth[key] === value,
([key, value]) => originalOAuth[key] === value,
);
if (unchanged) return connection;
const nextConfig = {
@@ -13342,6 +13400,19 @@ export function toolAccessService(
),
)
.limit(1);
// A renamed connection can leave its old profile name in use. Resolve
// names against profiles too, including when finalizing an installed draft.
const otherProfiles = await tx
.select({ name: toolProfiles.name })
.from(toolProfiles)
.where(and(
eq(toolProfiles.companyId, companyId),
ne(toolProfiles.profileKey, profileKey),
));
const profileName = nextAvailableConnectionName(
connection.name,
otherProfiles.map((profile) => profile.name),
);
let profileId: string;
if (existingProfile) {
if (input.preserveExistingAccess) {
@@ -13421,7 +13492,7 @@ export function toolAccessService(
const [updated] = await tx
.update(toolProfiles)
.set({
name: connection.name,
name: profileName,
description: `Access profile for ${connection.name}.`,
status: "active",
defaultAction: "deny",
@@ -13440,7 +13511,7 @@ export function toolAccessService(
.values({
companyId,
profileKey,
name: connection.name,
name: profileName,
description: `Access profile for ${connection.name}.`,
status: "active",
defaultAction: "deny",
@@ -14140,7 +14211,9 @@ export function toolAccessService(
issuer:
managedProfile.provider === "github"
? "https://github.com"
: "https://accounts.google.com",
: managedProfile.provider === "asana"
? "https://app.asana.com"
: "https://accounts.google.com",
resource: galleryMethod.defaults?.serverUrl ?? null,
registrationSource: null,
};
@@ -14164,6 +14237,9 @@ export function toolAccessService(
});
connection = resolvedClient.connection;
const client = resolvedClient.client;
if (galleryMethod?.oauthClientSecretRequired && !client.clientSecret) {
throw unprocessable("This app requires an OAuth client secret. Add it in the app setup before signing in.");
}
if (!client.clientId)
throw unprocessable(
`OAuth client id is not configured for ${endpoints.provider}`,
@@ -14768,9 +14844,9 @@ export function toolAccessService(
redemptionId: input.state,
});
const refreshToken = credentials.refreshToken;
if (profile.provider === "google" && !refreshToken) {
if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) {
throw unprocessable(
`Google did not return offline access. Reconnect ${providerName} and grant the requested scopes.`,
`${providerName} did not return offline access. Reconnect and grant the requested permissions.`,
{
code: "oauth_refresh_missing",
},
@@ -17183,6 +17259,7 @@ export function toolAccessService(
}
await annotateAiGrantHealth(connections);
await annotateGitHubAuthorization(connections, viewerUserId);
await annotateSavedOAuthClientSecret(connections, viewerUserId);
return connections;
},
@@ -17287,6 +17364,7 @@ export function toolAccessService(
);
await annotateAiGrantHealth([connection]);
await annotateGitHubAuthorization([connection], viewerUserId);
await annotateSavedOAuthClientSecret([connection], viewerUserId);
return connection;
},
+3 -2
View File
@@ -322,9 +322,9 @@ test("secondary chat navigation preserves layout, unique conversations, history,
await expect(nav.getByRole("link")).toHaveCount(2);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
await expect(editor).toContainText("Unsent draft for Alpha");
expect(await sidebar.boundingBox()).toEqual(landingBounds);
await alphaLink.click();
await page.getByRole("link", { name: "Configure Alpha", exact: true }).click();
await expect(page).toHaveURL(/\/agents\/.*\/runtime/);
await json(await request.post(`/api/issues/${beta.id}/comments`, {
@@ -335,6 +335,7 @@ test("secondary chat navigation preserves layout, unique conversations, history,
}));
await idle(request, betaPath);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
await compose.click();
await picker.getByRole("combobox").fill("Beta");
await picker.getByRole("combobox").press("Enter");
+2 -2
View File
@@ -29,7 +29,7 @@
"access": "public"
},
"dependencies": {
"@assistant-ui/react": "0.15.16",
"@assistant-ui/react": "0.15.21",
"@base-ui/react": "^1.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -59,7 +59,7 @@
"cmdk": "^1.1.1",
"i18next": "^26.4.0",
"lexical": "0.48.0",
"lucide-react": "^1.38.0",
"lucide-react": "^1.45.0",
"mermaid": "^11.17.2",
"motion": "^13.1.1",
"radix-ui": "^1.6.7",
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type Ref } from "react";
import { createPortal } from "react-dom";
import { Check, Copy, Terminal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
import { copyTextToClipboard } from "@/lib/clipboard";
import { motionMilliseconds, motionNumber } from "@/lib/onboarding-motion-tokens";
import { cn } from "@/lib/utils";
export interface AgentSetupPromptProps {
/** The complete text to preview and copy; never truncated on the clipboard. */
prompt: string;
title?: string;
description?: string;
label?: string;
variant?: "outline" | "ghost";
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
className?: string;
/** Carry over the clipboard result when generating a prompt already copied it. */
initialCopyStatus?: "idle" | "copied" | "failed";
onCopied?: () => void;
}
const agents = [
{ name: "Codex", src: "/brands/codex-color.svg", className: "" },
{ name: "ChatGPT", src: "/brands/apps/openai.svg", className: "dark:invert" },
{ name: "Claude", src: "/brands/claude-color.svg", className: "" },
];
function AgentLogos({ ref, hidden }: { ref: Ref<HTMLSpanElement>; hidden: boolean }) {
return (
<span ref={ref} className="agent-setup-logos" data-hidden={hidden} aria-hidden="true">
{agents.map((agent) => (
<span key={agent.name} className="agent-setup-logo-slot">
<span className="agent-setup-logo">
<img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} />
</span>
</span>
))}
</span>
);
}
function readLogoPositions(group: HTMLSpanElement | null) {
return Array.from(group?.querySelectorAll<HTMLElement>(".agent-setup-logo") ?? []).map((logo) => {
const bounds = logo.getBoundingClientRect();
const transform = getComputedStyle(logo).transform;
const matrix = transform && transform !== "none" ? new DOMMatrixReadOnly(transform) : null;
return {
// A rotated bounding box is larger than the logo. Anchor its unscaled
// box at the same center, preserving the current hover angle separately.
left: bounds.left + (bounds.width - logo.offsetWidth) / 2,
top: bounds.top + (bounds.height - logo.offsetHeight) / 2,
width: logo.offsetWidth,
height: logo.offsetHeight,
transform: `rotate(${matrix ? Math.atan2(matrix.b, matrix.a) * 180 / Math.PI : 0}deg)`,
};
});
}
/** A small handoff to the user's agent, with an inspectable prompt and local copy feedback. */
export function AgentSetupPrompt({
prompt,
title = "Agent setup",
description = "Paste this into your agent to take care of setup.",
label = "Set up with an agent",
variant = "outline",
side = "top",
align = "start",
className,
initialCopyStatus = "idle",
onCopied,
}: AgentSetupPromptProps) {
const id = useId();
const reducedMotion = usePrefersReducedMotion();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"idle" | "copying" | "copied" | "failed">(initialCopyStatus);
const [flight, setFlight] = useState<{ origins: ReturnType<typeof readLogoPositions>; toPreview: boolean } | null>(null);
const triggerLogos = useRef<HTMLSpanElement>(null);
const previewLogos = useRef<HTMLSpanElement>(null);
const flyingLogos = useRef<HTMLSpanElement>(null);
const activationOrigins = useRef<ReturnType<typeof readLogoPositions> | null>(null);
const copyAttempt = useRef(0);
const hasUserCopyAttempt = useRef(false);
const copying = useRef(false);
const copyButton = useRef<HTMLButtonElement>(null);
useEffect(() => {
setStatus("idle");
copying.current = false;
hasUserCopyAttempt.current = false;
// A pending clipboard result must not label a changed prompt as copied.
return () => { copyAttempt.current += 1; };
}, [prompt]);
useEffect(() => {
// A delayed generation-time copy must not replace the user's newer result.
if (!hasUserCopyAttempt.current) setStatus(initialCopyStatus);
}, [prompt, initialCopyStatus]);
useEffect(() => {
if (status !== "copied") return;
const hold = motionMilliseconds("--agent-setup-copied-hold");
if (hold <= 0) return;
const timeout = window.setTimeout(() => setStatus("idle"), hold);
return () => window.clearTimeout(timeout);
}, [status]);
useLayoutEffect(() => {
if (!flight) return;
if (reducedMotion || typeof Element.prototype.animate !== "function") {
setFlight(null);
return;
}
let frame: number;
let cancelled = false;
let animations: Animation[] = [];
const finish = () => { if (!cancelled) setFlight(null); };
const start = () => {
const destination = flight.toPreview ? previewLogos.current : triggerLogos.current;
// Radix publishes this measurement only after positioning its portal.
// Until then the full-size flight logos remain at the captured origin.
if (!destination || (flight.toPreview && !getComputedStyle(destination).getPropertyValue("--radix-popover-content-transform-origin").trim())) {
frame = requestAnimationFrame(start);
return;
}
const targets = readLogoPositions(destination);
const logos = flyingLogos.current?.querySelectorAll<HTMLElement>(".agent-setup-logo");
const easing = getComputedStyle(document.documentElement).getPropertyValue("--motion-ease-out-expo").trim();
animations = Array.from(logos ?? []).map((logo, index) => logo.animate(
[flight.origins[index], targets[index]].map(({ left, top, transform }) => ({ left: `${left}px`, top: `${top}px`, transform })),
{ duration: motionMilliseconds("--agent-setup-flight-duration"), easing, fill: "both" },
));
void Promise.all(animations.map((animation) => animation.finished)).then(finish, () => {});
};
frame = requestAnimationFrame(start);
// If the viewport moves mid-flight, settle at its live destination.
window.addEventListener("resize", finish);
document.addEventListener("scroll", finish, true);
return () => {
cancelled = true;
cancelAnimationFrame(frame);
animations.forEach((animation) => animation.cancel());
window.removeEventListener("resize", finish);
document.removeEventListener("scroll", finish, true);
};
}, [flight, reducedMotion]);
function changeOpen(nextOpen: boolean) {
const origins = activationOrigins.current ?? readLogoPositions(flyingLogos.current ?? (open ? previewLogos.current : triggerLogos.current));
activationOrigins.current = null;
setFlight(!reducedMotion && origins.length === agents.length ? { origins, toPreview: nextOpen } : null);
setOpen(nextOpen);
}
async function copyPrompt() {
if (copying.current || !prompt.trim()) return;
hasUserCopyAttempt.current = true;
copying.current = true;
const attempt = ++copyAttempt.current;
setStatus("copying");
try {
await copyTextToClipboard(prompt);
if (attempt === copyAttempt.current) {
setStatus("copied");
onCopied?.();
}
} catch {
if (attempt === copyAttempt.current) setStatus("failed");
} finally {
if (attempt === copyAttempt.current) copying.current = false;
}
}
return (
<>
<Popover open={open} onOpenChange={changeOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={variant}
aria-label={label}
data-copied={status === "copied"}
onClick={(event) => {
// The trigger remains a copy action while open. Dismiss with
// Close, Escape, or outside interaction without writing again.
if (open) event.preventDefault();
else activationOrigins.current = readLogoPositions(flyingLogos.current ?? triggerLogos.current);
void copyPrompt();
}}
className={cn("agent-setup-trigger h-auto min-h-10 max-w-full gap-3 whitespace-normal text-left", variant === "ghost" && "w-full justify-start", className)}
>
{status === "copied" ? <Check className="agent-setup-check size-4" /> : <Terminal className="size-4 text-muted-foreground" />}
<span className="relative min-w-0">
<span className={status === "copied" ? "invisible" : undefined}>{label}</span>
{status === "copied" && <span className="agent-setup-confirmation absolute inset-0 flex items-center">Copied!</span>}
</span>
<span className="agent-setup-trigger-logos"><AgentLogos ref={triggerLogos} hidden={open || Boolean(flight)} /></span>
</Button>
</PopoverTrigger>
<PopoverContent
side={side}
align={align}
collisionPadding={motionNumber("--agent-setup-collision-padding")}
className="agent-setup-popover flex flex-col gap-4 rounded-xl p-4"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`}
onOpenAutoFocus={(event) => {
event.preventDefault();
copyButton.current?.focus();
}}
onFocusOutside={(event) => {
// The legacy clipboard fallback briefly focuses a textarea in
// document.body. Keep the preview open so its result stays visible.
if (copying.current) event.preventDefault();
}}
>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h2 id={`${id}-title`} className="text-sm font-semibold">{title}</h2>
<p id={`${id}-description`} className="text-sm leading-relaxed text-muted-foreground">{description}</p>
</div>
<Button type="button" variant="ghost" size="icon-xs" aria-label="Close agent setup" onClick={() => changeOpen(false)}>
<X className="size-3.5" />
</Button>
</div>
<div className="agent-setup-preview-wrap relative pt-2">
<div className="agent-setup-preview-logos absolute right-3 top-0 z-10"><AgentLogos ref={previewLogos} hidden={Boolean(flight)} /></div>
{status === "failed" ? (
<Textarea
aria-label="Setup prompt"
readOnly
value={prompt}
onFocus={(event) => event.currentTarget.select()}
className="agent-setup-preview resize-none rounded-lg bg-muted/50 p-3 pt-5 font-mono text-xs leading-relaxed"
/>
) : (
<pre tabIndex={0} aria-label="Setup prompt" className="agent-setup-preview m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border/60 bg-muted/50 p-3 pt-5 font-mono text-xs leading-relaxed text-muted-foreground">{prompt}</pre>
)}
</div>
<div className="flex flex-col gap-2">
{status === "failed" && (
<p role="alert" className="text-xs text-destructive">Could not copy automatically. Select and copy the prompt above, or try again.</p>
)}
<Button
ref={copyButton}
type="button"
variant="cta"
className="agent-setup-copy w-full"
data-copied={status === "copied"}
aria-disabled={status === "copying" || !prompt.trim()}
onClick={() => { if (prompt.trim()) void copyPrompt(); }}
>
{status === "copied" ? <Check className="agent-setup-check size-4" /> : <Copy className="size-4" />}
{status === "copied" ? "Copied to clipboard" : status === "copying" ? "Copying…" : status === "failed" ? "Try copying again" : "Copy prompt"}
</Button>
<p className="text-center text-xs text-muted-foreground">
{status === "copied" ? "Ready to paste into your agent." : "Works with Codex, ChatGPT, Claude, and more."}
</p>
</div>
</PopoverContent>
</Popover>
<span role="status" className="sr-only">{status === "copied" ? "Setup prompt copied. Ready to paste into your agent." : ""}</span>
{flight && createPortal(
<span ref={flyingLogos} className="agent-setup-flight-layer" aria-hidden="true">
{agents.map((agent, index) => (
<span key={agent.name} className="agent-setup-logo agent-setup-flying-logo" style={flight.origins[index]}>
<img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} />
</span>
))}
</span>,
document.body,
)}
</>
);
}
@@ -75,7 +75,7 @@ describe("IssueContinuationHandoff", () => {
expect(container.textContent).not.toContain("Resume from the activity tab.");
const copyButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Copy"));
.find((button) => button.getAttribute("aria-label") === "Continue with an agent");
expect(copyButton).toBeTruthy();
await act(async () => {
@@ -84,6 +84,7 @@ describe("IssueContinuationHandoff", () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(handoff.body);
expect(container.textContent).toContain("Copied");
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(handoff.body);
await act(async () => {
root.unmount();
+10 -32
View File
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { IssueDocument } from "@paperclipai/shared";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { copyTextToClipboard } from "@/lib/clipboard";
type IssueContinuationHandoffProps = {
document: IssueDocument | null | undefined;
@@ -20,18 +19,8 @@ export function IssueContinuationHandoff({
externalReferences,
}: IssueContinuationHandoffProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [highlighted, setHighlighted] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!document || focusSignal <= 0) return;
@@ -42,20 +31,6 @@ export function IssueContinuationHandoff({
return () => clearTimeout(timer);
}, [document, focusSignal]);
const copyBody = useCallback(async () => {
if (!document) return;
try {
await copyTextToClipboard(document.body);
} catch {
return;
}
setCopied(true);
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);
}
copiedTimerRef.current = setTimeout(() => setCopied(false), 1500);
}, [document]);
if (!document) return null;
const title = document.title?.trim() || "Continuation handoff";
@@ -92,10 +67,13 @@ export function IssueContinuationHandoff({
{document.latestRevisionNumber > 0 ? ` - revision ${document.latestRevisionNumber}` : ""}
</div>
</div>
<Button variant="ghost" size="sm" onClick={copyBody} className="shrink-0">
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" /> : <Copy className="mr-1.5 h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
<AgentSetupPrompt
prompt={document.body}
label="Continue with an agent"
title="Task handoff"
description="Paste this into your agent to continue from this handoff."
align="end"
/>
</div>
{expanded ? (
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
+50 -1
View File
@@ -184,7 +184,12 @@ it("keeps agent-only invitations reachable from the new-agent flow", async () =>
allowedJoinTypes: "agent", humanRole: null, agentMessage: "Help with research",
});
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
expect(invites.copy).toHaveBeenCalled();
expect(invites.copy).toHaveBeenCalledTimes(1);
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("true");
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Copy onboarding prompt"]')!.click());
expect(invites.copy).toHaveBeenCalledTimes(2);
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(invites.copy.mock.calls[0][0]);
expect(state.navigate).not.toHaveBeenCalled();
});
@@ -197,3 +202,47 @@ it("keeps the generated invitation readable when clipboard access fails", async
expect(document.body.textContent).toContain("Copy the prompt manually");
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
});
it("clears the invitation copy failure after retrying without generating another invite", async () => {
invites.copy.mockRejectedValueOnce(new Error("Clipboard unavailable"));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(document.body.textContent).toContain("Clipboard unavailable");
await click("Copy onboarding prompt");
expect(invites.copy).toHaveBeenCalledTimes(2);
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
expect(document.body.textContent).not.toContain("Clipboard unavailable");
expect(document.body.textContent).toContain("Copied to clipboard");
});
it("shows the generated invitation while automatic clipboard access is pending", async () => {
let finishCopy!: () => void;
invites.copy.mockImplementationOnce(() => new Promise<void>((resolve) => { finishCopy = resolve; }));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("false");
await act(async () => finishCopy());
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("true");
});
it("keeps a newer user copy result when the automatic invitation copy fails", async () => {
let failAutomaticCopy!: (error: Error) => void;
let finishUserCopy!: () => void;
invites.copy
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => { failAutomaticCopy = reject; }))
.mockImplementationOnce(() => new Promise<void>((resolve) => { finishUserCopy = resolve; }));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
await click("Copy onboarding prompt");
await act(async () => failAutomaticCopy(new Error("Automatic copy failed")));
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copying…");
await act(async () => finishUserCopy());
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(document.body.textContent).not.toContain("Clipboard unavailable");
expect(invites.copy).toHaveBeenCalledTimes(2);
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
});
@@ -4,6 +4,7 @@ import { accessApi } from "@/api/access";
import { queryKeys } from "@/lib/queryKeys";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
@@ -21,17 +22,8 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
return () => { mounted.current = false; };
}, []);
const [message, setMessage] = useState("");
const [prompt, setPrompt] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
async function copy(value: string) {
try {
await copyTextToClipboard(value);
if (mounted.current) { setCopied(true); setCopyError(false); }
} catch {
if (mounted.current) setCopyError(true);
}
}
const [result, setResult] = useState<{ prompt: string; copyStatus: "idle" | "copied" | "failed" } | null>(null);
const prompt = result?.prompt;
const createInvite = useMutation({
mutationFn: async () => {
const invite = await accessApi.createCompanyInvite(companyId, {
@@ -51,8 +43,15 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
},
onSuccess: async (value) => {
if (!mounted.current) return;
setPrompt(value);
await copy(value);
// Keep the invitation readable while a browser clipboard request is pending.
setResult({ prompt: value, copyStatus: "idle" });
let copyStatus: "copied" | "failed" = "copied";
try {
await copyTextToClipboard(value);
} catch {
copyStatus = "failed";
}
if (mounted.current) setResult((current) => current?.copyStatus === "idle" ? { ...current, copyStatus } : current);
},
});
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
@@ -64,10 +63,17 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
</DialogDescription>
{prompt ? <>
<Textarea aria-label="Agent onboarding prompt" readOnly value={prompt} className="min-h-64 font-mono text-xs" />
{copyError && <p role="alert" className="text-sm text-muted-foreground">Clipboard unavailable. Copy the prompt manually from the field above.</p>}
<div className="flex justify-between gap-4">
{result?.copyStatus === "failed" && <p role="alert" className="text-sm text-muted-foreground">Clipboard unavailable. Copy the prompt manually from the field above, or use the button below to try again.</p>}
<div className="flex flex-wrap items-center justify-between gap-4">
<Button variant="ghost" onClick={onClose}>Done</Button>
<Button variant="outline" onClick={() => void copy(prompt)}>{copied ? "Copied prompt" : "Copy prompt"}</Button>
<AgentSetupPrompt
prompt={prompt}
label="Copy onboarding prompt"
title="Invite your agent"
description="Paste this into your external agent to request access to your organization."
initialCopyStatus={result?.copyStatus}
onCopied={() => setResult((current) => current && { ...current, copyStatus: "copied" })}
/>
</div>
</> : <>
<label className="space-y-2 text-sm">
@@ -3,6 +3,7 @@ import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
export function CopyField({
label,
@@ -64,8 +65,6 @@ export function CopyField({
}
export function AgentInstructions({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
return (
<section
aria-label="Agent instructions"
@@ -78,41 +77,12 @@ export function AgentInstructions({ value }: { value: string }) {
authentication key, and step-by-step instructions.
</p>
</div>
<Button
variant="outline"
size="sm"
aria-label="Copy for your agent"
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
setError(false);
} catch {
setError(true);
}
}}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? "Copied instructions" : "Copy for your agent"}
</Button>
{error && (
<div className="space-y-2">
<p role="alert" className="text-xs text-destructive">
Copy failed. Select and copy the instructions below.
</p>
<textarea
readOnly
aria-label="Agent instructions text"
value={value}
rows={5}
className="w-full rounded-md border border-input bg-background p-3 text-sm"
/>
</div>
)}
<AgentSetupPrompt
prompt={value}
label="Copy for your agent"
title="Webhook setup"
description="Paste this into your agent to connect this webhook to your routine."
/>
</section>
);
}
@@ -979,7 +979,12 @@ function StandardConnectionSetupFlow({
const fullRequestedDefinition = requestedAppKey
? getConnectableAppDefinition(requestedAppKey)
: null;
const requestedDefinitionUsesManagedConnector = Boolean(
const customOAuthMethod = fullRequestedDefinition?.methods.find((candidate) =>
connectionMethodAcceptsCustomerOAuthClient(candidate)
&& !connectionMethodSupportsAutomaticOAuth(candidate),
);
const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key);
const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && Boolean(
fullRequestedDefinition?.methods.some((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
|| candidate.oauthStrategy === "paperclip_id_connector"
@@ -1613,14 +1618,16 @@ function StandardConnectionSetupFlow({
}, [resumeConnectionId]);
useEffect(() => {
const savedConnection = resumeConnection ?? reconnectConnection;
const savedOAuth = savedConnection?.config?.oauth as Record<string, unknown> | undefined;
if (
!resumeConnection
!savedConnection
|| !entry
|| resumeConnection.status !== "draft"
|| hydratedResumeConnectionIdRef.current === resumeConnection.id
|| (savedConnection.status !== "draft" && savedOAuth?.clientRegistrationSource !== "manual")
|| hydratedResumeConnectionIdRef.current === savedConnection.id
) return;
const storedConfig = resumeConnection.config && typeof resumeConnection.config === "object"
? resumeConnection.config
const storedConfig = savedConnection.config && typeof savedConnection.config === "object"
? savedConnection.config
: {};
const storedSource = typeof storedConfig.sourceTemplateKey === "string"
? storedConfig.sourceTemplateKey
@@ -1635,7 +1642,7 @@ function StandardConnectionSetupFlow({
(candidate) => candidate.key === storedMethodKey,
) ?? null
: null;
setGalleryName(resumeConnection.name || entry.name);
setGalleryName(savedConnection.name || entry.name);
if (resumedMethod) {
setConnectionMethodKey(resumedMethod.key);
const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object"
@@ -1676,9 +1683,9 @@ function StandardConnectionSetupFlow({
setOAuthError(null);
}
setStep("key");
hydratedResumeConnectionIdRef.current = resumeConnection.id;
setHydratedResumeConnectionId(resumeConnection.id);
}, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection]);
hydratedResumeConnectionIdRef.current = savedConnection.id;
setHydratedResumeConnectionId(savedConnection.id);
}, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection, reconnectConnection]);
/**
* Commit the connection: action defaults, agent reach, and installs.
@@ -1971,7 +1978,7 @@ function StandardConnectionSetupFlow({
)
) : null;
// A provider can advertise registration and still refuse this deployment's
// callback (Asana refuses hosted ones). When the method also accepts an
// callback. When the method also accepts an
// operator's own OAuth client, that client is the recovery path, so it sits
// in the same Advanced panel and opens itself once sign-in has failed.
const automaticCustomerClientMethod = automaticOAuthEntry
@@ -2295,6 +2302,11 @@ function StandardConnectionSetupFlow({
<p className="mt-2 text-sm text-muted-foreground">
This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.
</p>
{customOAuthMethod ? (
<Button type="button" variant="link" className="mt-4 h-auto p-0 text-xs" onClick={() => setConnectionMethodKey(customOAuthMethod.key)}>
Use your own {entry.name} OAuth app
</Button>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={backToGallery}>Back</Button>
<Button type="button" disabled={galleryQuery.isFetching} onClick={() => void galleryQuery.refetch()}>
@@ -2320,6 +2332,12 @@ function StandardConnectionSetupFlow({
</div>
</div>
{customOAuthMethod ? (
<Button type="button" variant="link" className="mt-4 h-auto p-0 text-xs" onClick={() => setConnectionMethodKey(customOAuthMethod.key)}>
Use your own {entry.name} OAuth app
</Button>
) : null}
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
<InlineBanner tone="danger" className="mt-4">
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
@@ -2368,6 +2386,11 @@ function StandardConnectionSetupFlow({
oauthClientId={curatedOAuthClientId}
onOAuthClientIdChange={setCuratedOAuthClientId}
oauthClientSecret={curatedOAuthClientSecret}
canReuseOAuthClientSecret={Boolean(
identityConnection?.config?.oauth
&& (identityConnection.config.oauth as Record<string, unknown>).clientId === curatedOAuthClientId.trim()
&& identityConnection.hasSavedOAuthClientSecret
)}
onOAuthClientSecretChange={setCuratedOAuthClientSecret}
credentialSource={credentialSource}
vercelConnector={vercelConnector}
@@ -3401,6 +3424,7 @@ function KeyStep({
oauthClientId,
onOAuthClientIdChange,
oauthClientSecret,
canReuseOAuthClientSecret,
onOAuthClientSecretChange,
credentialSource,
vercelConnector,
@@ -3425,6 +3449,7 @@ function KeyStep({
oauthClientId: string;
onOAuthClientIdChange: (next: string) => void;
oauthClientSecret: string;
canReuseOAuthClientSecret: boolean;
onOAuthClientSecretChange: (next: string) => void;
credentialSource: ToolConnectionCredentialSource;
vercelConnector: string;
@@ -3515,7 +3540,10 @@ function KeyStep({
const acceptsCustomerOAuthClient = connectionMethodAcceptsCustomerOAuthClient(method);
const customerOAuthClientRequired = acceptsCustomerOAuthClient
&& !connectionMethodSupportsAutomaticOAuth(method);
const oauthClientFilled = usingVercel || !customerOAuthClientRequired || oauthClientId.trim().length > 0;
const oauthClientFilled = usingVercel || !customerOAuthClientRequired || (
oauthClientId.trim().length > 0
&& (!method?.oauthClientSecretRequired || canReuseOAuthClientSecret || oauthClientSecret.trim().length > 0)
);
const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0;
const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient
? oauthCallbackUrlForBrowser()
@@ -3565,27 +3593,26 @@ function KeyStep({
{!capabilityKey && <p className="mt-2 text-xs text-muted-foreground">Choose an access level to continue.</p>}
</div>
) : null;
const managedGoogleMethod = capabilityMethods.find((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
&& isGoogleWorkspaceConnectorProfileId(candidate.connectorProfile ?? ""),
const managedOAuthMethod = capabilityMethods.find((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector",
);
const customerGoogleMethod = managedGoogleMethod && capabilityMethods.find((candidate) =>
const customerOAuthMethod = managedOAuthMethod && capabilityMethods.find((candidate) =>
connectionMethodAcceptsCustomerOAuthClient(candidate)
&& !connectionMethodSupportsAutomaticOAuth(candidate),
);
const usingCustomGoogleOAuth = method?.key === customerGoogleMethod?.key;
const googleOAuthFieldsId = useId();
const authenticationSelection = managedGoogleMethod && customerGoogleMethod && capabilityMethods.length === 2 ? (
const usingCustomOAuth = method?.key === customerOAuthMethod?.key;
const customOAuthFieldsId = useId();
const authenticationSelection = managedOAuthMethod && customerOAuthMethod && capabilityMethods.length === 2 ? (
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
aria-expanded={usingCustomGoogleOAuth}
aria-controls={googleOAuthFieldsId}
aria-expanded={usingCustomOAuth}
aria-controls={customOAuthFieldsId}
disabled={submitting}
onClick={() => onMethodChange(usingCustomGoogleOAuth ? managedGoogleMethod : customerGoogleMethod)}
onClick={() => onMethodChange(usingCustomOAuth ? managedOAuthMethod : customerOAuthMethod)}
>
{usingCustomGoogleOAuth ? "Use Paperclip instead" : "Use your own Google OAuth app"}
{usingCustomOAuth ? "Use Paperclip instead" : `Use your own ${isGoogleWorkspaceConnectorProfileId(managedOAuthMethod.connectorProfile ?? "") ? "Google" : entry.name} OAuth app`}
</Button>
) : capabilityMethods.length > 1 ? (
// PAP-659 C1: the ranked default is already selected. This stays as the
@@ -3614,7 +3641,7 @@ function KeyStep({
|| hasAlternateMethods;
// Keep the disclosure open when what is inside it is load-bearing right now:
// a non-default method in use, or a selection the connector still needs.
const forceAdvancedOpen = usingCustomGoogleOAuth || !hasMethodSelection;
const forceAdvancedOpen = usingCustomOAuth || !hasMethodSelection;
const advancedSettings = hasAdvancedSettings ? (
<div className="space-y-6">
{capabilitySelection}
@@ -3705,7 +3732,8 @@ function KeyStep({
);
}
const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl;
const managedAsana = method?.connectorProfile === "asana.mcp";
const requirementsUrl = managedAsana ? null : method?.consoleLinks?.docs ?? entry.docsUrl;
return (
<div className="mx-auto max-w-xl">
@@ -3724,6 +3752,9 @@ function KeyStep({
) : null}
<div className="space-y-6">
{managedAsana ? (
<p className="text-sm text-muted-foreground">Sign in with Asana to choose your workspace and connect it to Paperclip.</p>
) : null}
{method?.capabilityProfile && (
<p className="text-sm text-muted-foreground">
{method.capabilityProfile.label}: {method.capabilityProfile.description}
@@ -3778,7 +3809,7 @@ function KeyStep({
{!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? (
<div id={googleOAuthFieldsId} role="region" aria-label="Your OAuth app">
<div id={customOAuthFieldsId} role="region" aria-label="Your OAuth app">
<OAuthClientFields
entry={entry}
method={method}
@@ -3892,7 +3923,7 @@ function OAuthClientFields({
<div className="space-y-4 rounded-lg border border-border p-4">
<div>
<div className="text-sm font-medium text-foreground">
{required ? `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
{required ? method.oauthClientSecretRequired ? "Your OAuth app" : `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
</div>
{/*
When these fields are required it is a provider limitation, not a step
@@ -3900,7 +3931,7 @@ function OAuthClientFields({
exception it is rather than as this connector's normal path.
*/}
<p className="mt-1 text-xs text-muted-foreground">
{required
{method.oauthClientSecretRequired ? method.guidanceMd : required
? `${entry.name} does not let Paperclip register itself automatically, so this connector needs an OAuth app you create. Add the callback URL below in ${entry.name}, then paste the client details back here.`
: `Register Paperclip's callback URI in ${entry.name}, then enter the customer-owned client details.`}
</p>
@@ -3956,7 +3987,7 @@ function OAuthClientFields({
value={clientSecret}
onChange={(event) => onClientSecretChange(event.target.value)}
autoComplete="off"
placeholder="Optional for public clients"
placeholder={method.oauthClientSecretRequired ? "Required" : "Optional for public clients"}
className="mt-2 h-11 font-mono"
/>
</div>
+76
View File
@@ -3217,3 +3217,79 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.skill-import-enter, .skill-import-sweep { animation: none; }
.skill-import-progress::-webkit-progress-value { transition: none; }
}
/* Agent setup handoff: the same logo stack travels from trigger to prompt. */
:root {
--agent-setup-flight-duration: var(--motion-duration-deliberate);
--agent-setup-copied-hold: 2400ms;
--agent-setup-popover-width: 21rem;
--agent-setup-collision-padding: 12;
--agent-setup-preview-height: clamp(6rem, 27dvh, 12rem);
--agent-setup-logo-size: calc(var(--spacing) * 7);
--agent-setup-logo-overlap: calc(var(--spacing) * -1.5);
--agent-setup-logo-lift: calc(var(--spacing) * -1.5);
--agent-setup-logo-tilt: 14deg;
}
.agent-setup-trigger-logos {
display: inline-flex;
flex-shrink: 0;
width: calc(var(--agent-setup-logo-size) * 3 + var(--agent-setup-logo-overlap) * 2);
height: var(--agent-setup-logo-size);
margin-left: auto;
}
.agent-setup-logos { display: inline-flex; isolation: isolate; }
.agent-setup-logos[data-hidden="true"] { visibility: hidden; }
.agent-setup-logo-slot { display: inline-flex; position: relative; }
.agent-setup-logo-slot + .agent-setup-logo-slot { margin-left: var(--agent-setup-logo-overlap); }
.agent-setup-flight-layer { position: fixed; inset: 0; z-index: 51; pointer-events: none; }
.agent-setup-flight-layer .agent-setup-flying-logo { position: absolute; transition: none; }
.agent-setup-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--agent-setup-logo-size);
height: var(--agent-setup-logo-size);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--popover);
box-shadow: var(--shadow-xs);
transition: transform var(--motion-duration-slow) var(--motion-ease-out-expo);
}
.agent-setup-trigger:is(:hover, :focus-visible) .agent-setup-logo-slot:nth-child(1) .agent-setup-logo {
transform: translateY(var(--agent-setup-logo-lift)) rotate(calc(-1 * var(--agent-setup-logo-tilt)));
}
.agent-setup-trigger:is(:hover, :focus-visible) .agent-setup-logo-slot:nth-child(2) .agent-setup-logo {
transform: translateY(calc(var(--agent-setup-logo-lift) * 1.6));
}
.agent-setup-trigger:is(:hover, :focus-visible) .agent-setup-logo-slot:nth-child(3) .agent-setup-logo {
transform: translateY(calc(var(--agent-setup-logo-lift) * 0.6)) rotate(var(--agent-setup-logo-tilt));
}
.agent-setup-popover {
width: min(var(--agent-setup-popover-width), calc(100vw - var(--spacing) * 6));
max-height: var(--radix-popover-content-available-height);
box-shadow: var(--shadow-profile-popover);
}
.agent-setup-popover[data-state="open"] { animation: tc-fade-in var(--motion-duration-base) var(--motion-ease-out-expo); }
.agent-setup-popover[data-state="closed"] { animation: none; }
.agent-setup-preview {
height: var(--agent-setup-preview-height);
max-height: max(3rem, calc(var(--radix-popover-content-available-height) - 12rem));
scrollbar-width: thin;
}
.agent-setup-preview-logos { transform: translateY(-25%); }
.agent-setup-preview-logos .agent-setup-logo-slot:first-child .agent-setup-logo { transform: rotate(calc(var(--agent-setup-logo-tilt) * -0.5)); }
.agent-setup-preview-logos .agent-setup-logo-slot:last-child .agent-setup-logo { transform: rotate(calc(var(--agent-setup-logo-tilt) * 0.5)); }
.agent-setup-check { animation: agent-setup-check var(--motion-duration-slow) var(--motion-ease-out-expo) both; }
.agent-setup-confirmation { animation: tc-fade-rise var(--motion-duration-base) var(--motion-ease-out-expo) both; }
.agent-setup-copy[aria-disabled="true"] { opacity: 0.5; }
@keyframes agent-setup-check {
from { opacity: 0; transform: scale(0.6) rotate(-20deg); }
to { opacity: 1; transform: scale(1) rotate(0); }
}
@media (prefers-reduced-motion: reduce) {
.agent-setup-logo { transition: none; transform: none !important; }
.agent-setup-popover, .agent-setup-check, .agent-setup-confirmation { animation: none !important; }
}
@media (max-height: 420px) {
.agent-setup-popover { overflow-y: auto; }
}
+7 -4
View File
@@ -177,8 +177,12 @@ function buildAgentIconMask(iconName: string | null): string | null {
if (cached) return cached;
const Icon = getAgentIcon(iconName);
const iconNode = resolveLucideIconNode(Icon);
if (!Array.isArray(iconNode) || iconNode.length === 0) return null;
// Lucide's component internals differ between browser and server builds.
// Keep the agent marker visible when its private iconNode is unavailable.
const iconNode = resolveLucideIconNode(Icon) ?? [
["rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }],
["path", { d: "M8 10h.01M16 10h.01M8 15h8" }],
];
const body = iconNode.map(([tag, attrs]) => {
const attrString = Object.entries(attrs)
@@ -220,8 +224,7 @@ function resolveLucideIconNode(
}
).render;
const rendered = typeof render === "function" ? render({}, null) : null;
// Lucide 1.45 passes structured icon data; older releases pass iconNode.
const renderedIconNode = rendered?.props?.iconNode ?? rendered?.props?.icon?.node;
const renderedIconNode = rendered?.props?.icon?.node ?? rendered?.props?.iconNode;
return Array.isArray(renderedIconNode) && renderedIconNode.length > 0
? renderedIconNode
: null;
+25 -1
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
import { clearLegacyChatMessageRequests } from "./chat-message-request";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
orderChatAgents,
parseRecentAgentChats,
recordedAgentChatIssueId,
recordAgentChatVisit,
} from "./recent-agent-chats";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
@@ -57,6 +58,29 @@ describe("agent chat navigation and session markers", () => {
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user2")!),
).toEqual(["agent4"]);
});
it("remembers an existing conversation and clears its identity for a new empty chat", () => {
recordAgentChatVisit("company-c", "user1", "agent1", "issue-1");
expect(recordedAgentChatIssueId("company-c", "user1", "agent1")).toBe("issue-1");
expect(recordedAgentChatIssueId("company-c", "user2", "agent1")).toBeNull();
recordAgentChatVisit("company-c", "user1", "agent1", null);
expect(recordedAgentChatIssueId("company-c", "user1", "agent1")).toBeNull();
});
it("recognizes a conversation cleared in another tab while retaining failed-write fallback", () => {
const storageKey = "paperclip.recentAgentChatIssue:company-d:user1:agent1";
recordAgentChatVisit("company-d", "user1", "agent1", "issue-1");
localStorage.removeItem(storageKey);
expect(recordedAgentChatIssueId("company-d", "user1", "agent1")).toBeNull();
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable");
});
try {
recordAgentChatVisit("company-d", "user1", "agent1", "issue-2");
expect(recordedAgentChatIssueId("company-d", "user1", "agent1")).toBe("issue-2");
} finally {
setItem.mockRestore();
}
});
it("removes legacy plaintext retry records", () => {
const scope = "company:user:agent";
const key = `paperclip:agent-chat-pending:${scope}`;
+25
View File
@@ -4,6 +4,9 @@ const eventName = "paperclip:recent-agent-chats";
const key = (company: string, user?: string | null) =>
`paperclip.recentAgentChats:${company}:${user ?? "__local_board__"}`;
const memory = new Map<string, string>();
const issueKey = (company: string, user: string | null | undefined, agentId: string) =>
`paperclip.recentAgentChatIssue:${company}:${user ?? "__local_board__"}:${agentId}`;
const rememberedIssues = new Map<string, string>();
function read(storageKey: string): string {
try {
return (
@@ -29,6 +32,7 @@ export function recordAgentChatVisit(
company: string,
user: string | null | undefined,
agentId: string,
issueId?: string | null,
) {
const storageKey = key(company, user);
const value = JSON.stringify(
@@ -43,8 +47,29 @@ export function recordAgentChatVisit(
} catch {
/* In-tab navigation still works without storage. */
}
if (issueId !== undefined) {
const storageIssueKey = issueKey(company, user, agentId);
try {
if (issueId) window.localStorage.setItem(storageIssueKey, issueId);
else window.localStorage.removeItem(storageIssueKey);
// Storage is authoritative when available, including when another tab
// removes this key. Only retain a fallback after a failed write.
rememberedIssues.delete(storageIssueKey);
} catch {
if (issueId) rememberedIssues.set(storageIssueKey, issueId);
else rememberedIssues.delete(storageIssueKey);
}
}
window.dispatchEvent(new Event(eventName));
}
export function recordedAgentChatIssueId(company: string, user: string | null | undefined, agentId: string): string | null {
const storageIssueKey = issueKey(company, user, agentId);
try {
return window.localStorage.getItem(storageIssueKey) ?? rememberedIssues.get(storageIssueKey) ?? null;
} catch {
return rememberedIssues.get(storageIssueKey) ?? null;
}
}
function subscribe(callback: () => void) {
window.addEventListener(eventName, callback);
window.addEventListener("storage", callback);
+3 -3
View File
@@ -48,9 +48,9 @@ export function AgentChat() {
creating.current = null;
}, [selectedCompanyId, userId, agent?.id]);
useEffect(() => {
if (enabled && agent && session.isFetched)
recordAgentChatVisit(agent.companyId, userId, agent.id);
}, [enabled, agent?.id, agent?.companyId, userId, session.isFetched]);
if (enabled && agent && session.isSuccess && chat.isSuccess)
recordAgentChatVisit(agent.companyId, userId, agent.id, chat.data?.id ?? null);
}, [enabled, agent?.id, agent?.companyId, userId, session.isSuccess, chat.isSuccess, chat.data?.id]);
const ensureIssue = useCallback(async () => {
if (!agent || !selectedCompanyId) throw new Error("Agent not found");
if (chat.data) return chat.data;
+150
View File
@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
import { AgentChats } from "./AgentChats";
const state = vi.hoisted(() => ({
companyId: "company-a",
userId: "user-a",
agents: [] as Agent[],
chats: [] as { id: string; conversationAgentId: string }[],
chatsFetched: true,
chatsError: null as Error | null,
sessionError: null as Error | null,
navigate: vi.fn(),
}));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({
useAgentChatNavigation: () => ({
companyId: state.companyId,
userId: state.userId,
enabled: true,
loaded: true,
agents: { data: state.agents, isFetched: true, isPending: false, error: null },
chats: { data: state.chats, isFetched: state.chatsFetched, isSuccess: state.chatsFetched && !state.chatsError, error: state.chatsError },
session: { isFetched: !state.sessionError, isPending: false, error: state.sessionError },
}),
useOpenAgentChat: () => vi.fn(),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => state.navigate,
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
}));
vi.mock("@/components/AgentAvatar", () => ({ AgentAvatar: () => null }));
let root: Root;
let container: HTMLDivElement;
const agent = (id: string, companyId = "company-a") => ({
id, companyId, name: id, urlKey: `${id}-slug`, role: "engineer", status: "idle",
} as Agent);
async function render() {
await act(async () => root.render(<AgentChats />));
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
state.companyId = "company-a";
state.userId = "user-a";
state.agents = [agent("alice"), agent("bob")];
state.chats = [];
state.chatsFetched = true;
state.chatsError = null;
state.sessionError = null;
state.navigate.mockReset();
for (const id of ["alice", "bob", "removed", "retired-id"])
recordAgentChatVisit("company-a", "user-a", id, null);
localStorage.setItem("paperclip.recentAgentChats:company-a:user-a", "[]");
localStorage.setItem("paperclip.recentAgentChats:company-b:user-a", "[]");
localStorage.setItem("paperclip.recentAgentChats:company-a:user-b", "[]");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
describe("Chat landing", () => {
it("opens the most recently visited agent and replaces the landing history entry", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "bob");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true });
expect(container.textContent).toContain("Opening chat");
});
it("skips unavailable saved agents and preserves the company and user scope", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "removed");
recordAgentChatVisit("company-b", "user-a", "other-company-agent");
recordAgentChatVisit("company-a", "user-b", "other-user-agent");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens historical chats for agents missing from the active roster", async () => {
recordAgentChatVisit("company-a", "user-a", "retired-id");
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
});
it("waits for history before skipping a recent historical chat", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "retired-id");
state.chatsFetched = false;
await render();
expect(state.navigate).not.toHaveBeenCalled();
state.chatsFetched = true;
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
});
it("shows the chooser when no saved chat remains available", async () => {
recordAgentChatVisit("company-a", "user-a", "removed");
await render();
expect(state.navigate).not.toHaveBeenCalled();
expect(container.textContent).toContain("Who would you like to talk to?");
});
it("skips a saved conversation that is no longer accessible even when its agent remains available", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "bob", "deleted-chat");
state.chats = [{ id: "chat-alice", conversationAgentId: "alice" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens an active agent when history fails", async () => {
recordAgentChatVisit("company-a", "user-a", "alice", "chat-alice");
state.chatsFetched = false;
state.chatsError = new Error("History unavailable");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens a visited empty chat without waiting for history", async () => {
recordAgentChatVisit("company-a", "user-a", "alice", null);
state.chatsFetched = false;
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("shows a retry state when identity fails instead of waiting for disabled history", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
state.sessionError = new Error("Session unavailable");
state.chatsFetched = false;
await render();
expect(state.navigate).not.toHaveBeenCalled();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load your chats");
});
});
+34 -6
View File
@@ -4,30 +4,58 @@ import { AgentAvatar } from "@/components/AgentAvatar";
import { Button } from "@/components/ui/button";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation";
import { Link } from "@/lib/router";
import { recordedAgentChatIssueId, useRecentAgentChats } from "@/lib/recent-agent-chats";
import { Link, useNavigate } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
export function AgentChats() {
const { setBreadcrumbs } = useBreadcrumbs();
const { companyId, userId, enabled, loaded, agents, session } = useAgentChatNavigation();
const { companyId, userId, enabled, loaded, agents, chats, session } = useAgentChatNavigation();
useEffect(() => setBreadcrumbs([{ label: "Chat" }]), [setBreadcrumbs]);
return <AgentChatsContent key={`${companyId}:${userId}`} companyId={companyId} userId={userId}
enabled={enabled} loaded={loaded} agents={agents} session={session} />;
enabled={enabled} loaded={loaded} agents={agents} chats={chats} session={session} />;
}
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "session">) {
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "chats" | "session">) {
const openChat = useOpenAgentChat(companyId, userId);
const navigate = useNavigate();
const recentIds = useRecentAgentChats(companyId ?? "", userId);
const [openingId, setOpeningId] = useState<string | null>(null);
const [openError, setOpenError] = useState<string | null>(null);
const blockingError = agents.error ?? session.error;
const firstId = recentIds[0];
const firstAgent = agents.data?.find(agent => agent.id === firstId);
// An empty chat with an active agent can reopen from the roster alone. A
// missing agent or a saved issue needs history to validate that visit.
const needsHistory = !!firstId && (!firstAgent || !!recordedAgentChatIssueId(companyId ?? "", userId, firstId));
const resolvingRecent = recentIds.length > 0 && !blockingError && (!agents.isFetched || !session.isFetched || (needsHistory && !chats.isFetched && !chats.error));
const recentChatPath = loaded && enabled && companyId && !blockingError && !resolvingRecent
? recentIds.map(id => {
const agent = agents.data?.find(item => item.id === id);
const historyIssue = chats.data?.find(chat => chat.conversationAgentId === id);
const savedIssueId = recordedAgentChatIssueId(companyId, userId, id);
// A deleted or inaccessible issue must not silently reopen as a new chat.
if (savedIssueId && chats.isSuccess && historyIssue?.id !== savedIssueId) return null;
if (agent) return `/chats/${encodeURIComponent(agentRouteRef(agent))}`;
if (historyIssue) return `/chats/${encodeURIComponent(id)}`;
return null;
}).find((path): path is string => path !== null)
: undefined;
useEffect(() => {
if (recentChatPath) navigate(recentChatPath, { replace: true });
}, [navigate, recentChatPath]);
if (!loaded) return <p role="status" className="text-sm text-muted-foreground">Loading chat…</p>;
if (!enabled) return <p className="text-sm text-muted-foreground">Agent Chat is disabled. Enable it in Experimental settings.</p>;
const error = agents.error ?? session.error;
if (!companyId) return <p className="text-sm text-muted-foreground">Select a company to start a conversation.</p>;
if (resolvingRecent || recentChatPath) return <p role="status" className="text-sm text-muted-foreground">Opening chat…</p>;
const error = blockingError ?? (recentIds.length > 0 ? chats.error : null);
return <div className="mx-auto flex h-full max-w-xl flex-col justify-center gap-6 px-4 py-12">
<div className="flex flex-col gap-3">
<MessageCircle className="size-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Who would you like to talk to?</h1>
<p className="text-sm leading-relaxed text-muted-foreground">Ask a question, think through an idea, or plan the next step with your team.</p>
</div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your agents.</p><Button variant="outline" onClick={() => { void agents.refetch(); void session.refetch(); }}>Try again</Button></div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your chats.</p><Button variant="outline" onClick={() => { void agents.refetch(); void chats.refetch(); void session.refetch(); }}>Try again</Button></div>
: agents.isPending || session.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
: <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(agents.data ?? []).slice(0, 6).map(agent => <button key={agent.id} type="button" disabled={openingId !== null}
+1 -1
View File
@@ -2286,7 +2286,7 @@ export function DesignGuide() {
</Section>
<Section title="Browser setup prompt">
<p className="text-sm text-muted-foreground">A shared copy action for provider setup instructions. Confirms success inline and offers selectable text if clipboard access fails.</p>
<p className="text-sm text-muted-foreground">Use AgentSetupPrompt for prompts handed to an external agent: connections, webhook setup, onboarding, and task handoffs. One click copies the complete prompt, opens its preview, and confirms success inline; clipboard failures offer selectable text.</p>
<SetupPrompt prompt="Design guide example. This is a preview, not a real provider setup request." />
</Section>
+64 -19
View File
@@ -53,6 +53,10 @@ const GITHUB_MANAGED = {
};
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!;
const ASANA_MANAGED = {
...ASANA,
ownershipAvailability: { ...ASANA.ownershipAvailability, platform_shared: true },
};
const BOX = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "box")!;
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!;
@@ -981,6 +985,45 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(gmail?.textContent).not.toContain("Coming soon");
});
it("defaults Asana to managed sign-in and allows a custom app before enrollment", async () => {
mockSearch.value = "source=asana";
listGalleryMock.mockResolvedValue({ apps: [{ ...ASANA, methods: ASANA.methods.filter((method) => !method.oauthStrategy), ownershipAvailability: {
...ASANA.ownershipAvailability, platform_shared: false,
} }] });
getCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "not_configured", origins: [] });
await render();
await passAccessStep();
expect(container.textContent).toContain("Connect with Paperclip");
await act(async () => { buttonByText("Use your own Asana OAuth app")!.click(); });
await flushReact();
expect(container.textContent).toContain("Your OAuth app");
expect(container.textContent).toContain("API apps do not work with Asana MCP");
expect(container.textContent).not.toContain("You must connect this instance");
await act(async () => { setInputValue(container.querySelector<HTMLInputElement>("#curated-oauth-client-id")!, "asana-client"); });
await flushReact();
expect(buttonByText("Continue to sign in")?.disabled).toBe(true);
expect(startCloudConnectorEnrollmentMock).not.toHaveBeenCalled();
});
it.each([true, false])("requires the acting user's saved Asana client secret for reuse (%s)", async (hasSavedOAuthClientSecret) => {
mockSearch.value = "source=asana&resume=conn-asana";
listGalleryMock.mockResolvedValue({ apps: [ASANA] });
listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-asana", status: "draft", metadata: { sourceTemplateKey: "asana" } }] });
listConnectionsMock.mockResolvedValue({ connections: [{
id: "conn-asana", applicationId: "app-asana", name: "Asana", authKind: "oauth",
credentialPolicy: "per_user", status: "draft", credentialSecretRefs: [], hasSavedOAuthClientSecret,
config: { sourceTemplateKey: "asana", connectionMethodKey: "mcp-own-oauth", oauth: { clientId: "saved-asana-client", clientRegistrationSource: "manual" } },
transportConfig: {},
}] });
await render();
await flushReact();
expect(container.querySelector<HTMLInputElement>("#curated-oauth-client-id")?.value).toBe("saved-asana-client");
expect(buttonByText("Continue to sign in")?.disabled).toBe(!hasSavedOAuthClientSecret);
await act(async () => { setInputValue(container.querySelector<HTMLInputElement>("#curated-oauth-client-id")!, "different-client"); });
await flushReact();
expect(buttonByText("Continue to sign in")?.disabled).toBe(true);
});
it("collects customer-owned OAuth client details for a curated manual OAuth app", async () => {
listGalleryMock.mockResolvedValue({ apps: [BOX] });
mockParams.appKey = "box";
@@ -1023,28 +1066,27 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
}));
});
it("gives Asana the one-click path now that its server advertises registration", async () => {
listGalleryMock.mockResolvedValue({ apps: [ASANA] });
it("connects Asana through the shared app without client credentials", async () => {
listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] });
mockParams.appKey = "asana";
await render();
await flushReact();
// No console detour: the connect screen is the handoff, not a form.
expect(container.textContent).not.toContain("needs its own OAuth app");
expect(container.querySelector("#curated-oauth-client-id")).toBeNull();
const primary = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.trim().startsWith("Continue to"),
);
const primary = buttonByText("Continue to sign in");
expect(primary).toBeTruthy();
expect(primary?.disabled).toBe(false);
await act(async () => { primary!.click(); });
await flushReact();
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
galleryKey: "asana", connectionMethodKey: "managed", grantKind: "user",
}));
});
it("offers Asana's own-OAuth-app fields as the recovery when registration is refused", async () => {
// Asana advertises registration but refuses hosted callbacks, so a failed
// sign-in must still leave the customer-owned client path within reach.
listGalleryMock.mockResolvedValue({ apps: [ASANA] });
it("allows a custom Asana app from the shared sign-in setup", async () => {
listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] });
mockParams.appKey = "asana";
connectAppMock.mockRejectedValueOnce(new Error("Asana refused the redirect URI."));
connectAppMock.mockResolvedValueOnce({
connectionId: "conn-asana",
application: { id: "app-asana", name: "Asana" },
@@ -1058,25 +1100,28 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
expect(container.querySelector("#curated-oauth-client-id")).toBeNull();
await act(async () => {
buttonByText("Continue to Asana")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await openAccessAdvanced();
await flushReact();
const customApp = buttonByText("Use your own Asana OAuth app");
expect(customApp).toBeTruthy();
await act(async () => { customApp!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
await flushReact();
const clientId = container.querySelector<HTMLInputElement>("#curated-oauth-client-id");
const clientSecret = container.querySelector<HTMLInputElement>("#curated-oauth-client-secret");
expect(clientId).toBeTruthy();
await act(async () => setInputValue(clientId!, "asana-own-client"));
await flushReact();
expect(clientSecret).toBeTruthy();
await act(async () => {
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
setInputValue(clientId!, "asana-own-client");
setInputValue(clientSecret!, "asana-own-secret");
});
await flushReact();
await act(async () => { buttonByText("Continue to sign in")!.click(); });
await flushReact();
expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({
galleryKey: "asana",
oauthClient: expect.objectContaining({ clientId: "asana-own-client" }),
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth",
oauthClient: { clientId: "asana-own-client", clientSecret: "asana-own-secret" },
}));
});
@@ -10,7 +10,7 @@ describe("Apps agent selector contract", () => {
const appConnect = source("./AppsConnect.tsx");
const connectionSetupFlow = source("../../features/connections/ConnectionSetupFlow.tsx");
const permissions = source("./app-detail/PermissionsPanel.tsx");
const tester = source("./app-detail/TestPanel.tsx");
const tester = source("./app-detail/ActionTestDialog.tsx");
const profiles = source("../tools/ProfilesTab.tsx");
const audit = source("../tools/AuditTab.tsx");
+4 -7
View File
@@ -15,14 +15,11 @@ describe("app connect policy", () => {
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(expect.arrayContaining(["jira", "notion", "sentry"]));
expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true);
expect(isMcpDirectOAuthConnectSlug("jira")).toBe(true);
// PAP-659 step 4: Asana's own OAuth metadata advertises registration, and
// live discovery now outranks its pinned customer-only ownership mode, so
// it reaches the provider directly instead of the client-ID form.
expect(isMcpDirectOAuthConnectSlug("asana")).toBe(true);
// Still false, and for two different reasons worth keeping apart: GitHub's
// one-click path is Paperclip-managed rather than direct, and Slack really
// does require a customer-registered OAuth client.
// Asana and GitHub need an available Paperclip-managed profile for
// one-click sign-in. The static catalog defaults to their custom-app path.
expect(isMcpDirectOAuthConnectSlug("asana")).toBe(false);
expect(isMcpDirectOAuthConnectSlug("github")).toBe(false);
// Slack requires a customer-registered OAuth client.
expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false);
expect(isMcpDirectOAuthConnectSlug(null)).toBe(false);
});
@@ -0,0 +1,295 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import { ActionTestDialog, errorHints } from "./ActionTestDialog";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) => getTestAgentAccessMock(connectionId, agentId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, requestId: string) => getTestCallStatusMock(connectionId, requestId),
declineActionRequest: (companyId: string, requestId: string) => declineActionRequestMock(companyId, requestId),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const entry = {
id: "asana-list-projects",
companyId: "company-1",
applicationId: "app-asana",
connectionId: "conn-1",
entryKind: "tool",
toolName: "list_projects",
title: "List projects",
description: "Find Asana projects.",
inputSchema: { type: "object", properties: {} },
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-09-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-09-01T00:00:00Z"),
lastSeenAt: new Date("2026-10-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-09-01T00:00:00Z"),
updatedAt: new Date("2026-10-01T00:00:00Z"),
} as ToolCatalogEntry;
const askFirstEntry = {
...entry,
id: "asana-create-task",
toolName: "create_task",
title: "Create task",
riskLevel: "write",
isReadOnly: false,
isWrite: true,
} as ToolCatalogEntry;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let client: QueryClient;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => { result = callback(); });
await result;
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
async function renderDialog(testEntry = entry) {
await act(() => root.render(
<QueryClientProvider client={client}>
<ActionTestDialog connectionId="conn-1" appName="Asana" entry={testEntry} open onOpenChange={() => undefined} />
</QueryClientProvider>,
));
await flushReact();
}
async function openAndRun(testEntry = entry) {
await renderDialog(testEntry);
const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === "Run");
expect(button).toBeTruthy();
await act(() => button!.click());
await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 260)); });
await flushReact();
}
function allowAskFirst() {
getTestAgentAccessMock.mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 0, askFirstCount: 1, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "create_task", gatewayToolName: "asana__create_task", displayName: "Create task", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "ask-invocation", actionRequestId: "request-1" });
}
function requestStatus(phase: "waiting" | "running" | "done" | "denied") {
return {
actionRequestId: "request-1", invocationId: "ask-invocation", phase,
parameters: {}, requestedAt: "2026-10-01T00:00:00.000Z",
resolvedAt: phase === "waiting" ? null : "2026-10-01T00:00:05.000Z",
durationMs: phase === "done" ? 1200 : null,
};
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listTestAgentsMock.mockReset().mockResolvedValue({
agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
});
getTestAgentAccessMock.mockReset().mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "list_projects", gatewayToolName: "asana__list_projects", displayName: "List projects", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset().mockResolvedValue(requestStatus("waiting"));
declineActionRequestMock.mockReset();
window.sessionStorage.clear();
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
window.sessionStorage.clear();
});
describe("Permissions action Test dialog", () => {
it("renders structured MCP rows and keeps the full response behind the raw disclosure", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "structured",
result: {
content: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}',
data: {
content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}' }],
structuredContent: { data: [{ name: "Q4 launch" }, { name: "Customer onboarding" }] },
isError: false, transport: "mcp_http",
},
},
});
await openAndRun();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", { agentId: "agent-ceo", toolName: "list_projects", parameters: {} });
expect(document.body.textContent).toContain("Worked. 2 rows came back.");
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
expect(document.body.textContent).not.toContain("structuredContent");
const raw = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Show raw response");
await act(() => raw!.click());
expect(document.body.textContent).toContain("structuredContent");
});
it("parses JSON text when structured content is absent", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "text",
result: { content: '{"data":[{"name":"Q4 launch"}]}', data: { content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"}]}' }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
});
it("uses structured content when an MCP result has no content blocks", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "structured-only",
result: { content: "", data: { structuredContent: { results: [{ name: "Northstar launch plan" }] }, isError: false } },
});
await openAndRun();
expect(document.body.textContent).toContain("Worked. 1 row came back.");
expect(document.body.querySelector("table")?.textContent).toContain("Northstar launch plan");
expect(document.body.textContent).not.toContain("structuredContent");
});
it("shows wide search results as readable cards with a safe page link", async () => {
const rows = [{
id: "11111111-2222-4333-8444-555555555555",
title: "Northstar launch plan",
url: "https://www.notion.so/11111111222243338444555555555555",
type: "page",
highlight: "The launch plan covers milestones, owners, and a detailed summary that should not stretch a table row in the dialog.",
timestamp: "2026-09-10T14:30:00.000Z",
}];
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "notion-wide",
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.querySelector("table")).toBeNull();
expect(document.body.textContent).toContain("Northstar launch plan");
expect(document.body.textContent).toContain("The launch plan covers milestones");
expect(document.body.querySelector('a[href="https://www.notion.so/11111111222243338444555555555555"]')?.textContent).toBe("Open link");
});
it("shows a non-link URL as a field in a result card", async () => {
const rows = [{ title: "Northstar launch plan", url: "notion://workspace/launch", description: "A long description that keeps the card layout useful even when the provider supplies a URL that cannot open in a browser." }];
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "non-link-url",
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], isError: false } },
});
await openAndRun();
expect(document.body.querySelector('a[href="notion://workspace/launch"]')).toBeNull();
expect(document.body.textContent).toContain("notion://workspace/launch");
});
it("keeps an ask-first request visible after reopening the dialog", async () => {
allowAskFirst();
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Sent for your OK.");
expect(document.body.textContent).toContain("Cancel this request");
expect(document.body.querySelector('a[href="/apps/conn-1/review"]')).toBeTruthy();
await act(() => root.unmount());
root = createRoot(container);
await renderDialog(askFirstEntry);
expect(runTestCallMock).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Sent for your OK.");
});
it("shows an approved request running and then the completed result", async () => {
allowAskFirst();
getTestCallStatusMock.mockResolvedValue(requestStatus("running"));
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Approved · running");
expect(document.body.textContent).not.toContain("Cancel this request");
getTestCallStatusMock.mockResolvedValue({ ...requestStatus("done"), result: [{ name: "Northstar launch plan" }] });
await act(async () => { await client.invalidateQueries({ queryKey: ["tools"] }); });
await flushReact();
expect(document.body.textContent).toContain("Worked.");
expect(document.body.textContent).toContain("Northstar launch plan");
expect(runTestCallMock).toHaveBeenCalledTimes(1);
});
it("shows a denied request without offering cancellation", async () => {
allowAskFirst();
getTestCallStatusMock.mockResolvedValue(requestStatus("denied"));
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Denied — see Review for why");
expect(document.body.textContent).not.toContain("Cancel this request");
});
it("shows a tool error and opens its raw response", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "error",
result: { content: "Asana denied access to this workspace.", data: { content: [{ type: "text", text: "Asana denied access to this workspace." }], isError: true }, error: "MCP tool returned an error result" },
});
await openAndRun();
expect(document.body.textContent).toContain("It didn't work.");
expect(document.body.textContent).toContain("Asana denied access to this workspace.");
expect(document.body.textContent).toContain("Hide raw response");
});
it("opens the raw response when an MCP block has an unexpected shape", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "malformed",
result: { content: "", data: { content: [{ type: "text", payload: "missing text" }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.textContent).toContain("Hide raw response");
expect(document.body.textContent).toContain("missing text");
});
it("keeps useful and generic error hints", () => {
expect(errorHints("Not found", "NOT_FOUND").join(" ")).toMatch(/check|verify|ID/i);
expect(errorHints("Unexpected provider failure", null).length).toBeGreaterThan(0);
});
});
@@ -4,7 +4,6 @@ import {
AlertTriangle,
Ban,
Check,
ChevronDown,
ChevronsUpDown,
Clock,
Loader2,
@@ -28,12 +27,6 @@ import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Dialog,
@@ -50,7 +43,6 @@ import {
} from "@/components/JsonSchemaForm";
import { cn, relativeTime } from "@/lib/utils";
import { appTabHref } from "../app-tabs";
import { formatActionPermissionSummary } from "./action-permission-summary";
// ---------------------------------------------------------------------------
// Small format helpers
@@ -67,13 +59,6 @@ function relTime(date: Date): string {
return t.charAt(0).toUpperCase() + t.slice(1);
}
/** Sub-line copy: first sentence of the catalog description, no trailing period. */
function actionSubLine(entry: ToolCatalogEntry): string | null {
if (!entry.description) return null;
const firstSentence = entry.description.split(/(?<=\.)\s/)[0] ?? entry.description;
return firstSentence.replace(/\.+$/, "").trim() || null;
}
// ---------------------------------------------------------------------------
// Decision badges
// ---------------------------------------------------------------------------
@@ -88,9 +73,7 @@ const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000;
const TEST_ACCESS_GC_TIME_MS = 30 * 60_000;
/**
* Focused action tester used by the combined Permissions page. The modal keeps
* the existing schema form and result renderer, but scopes agent selection and
* test state to the action the user opened.
* Action tester opened from an action row on the Permissions page.
*/
export function ActionTestDialog({
connectionId,
@@ -185,7 +168,6 @@ export function ActionTestDialog({
onSelect={setRequestedAgentId}
connectionId={connectionId}
appName={appName}
inline
/>
<DecisionBadge decision={decision} />
</div>
@@ -236,262 +218,6 @@ function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) {
);
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function TestPanel({
connectionId,
appName,
active,
quarantined = [],
}: {
connectionId: string;
appName: string;
/** Active (non-quarantined, non-removed) catalog entries. */
active: ToolCatalogEntry[];
/** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */
quarantined?: ToolCatalogEntry[];
}) {
const hasActions = active.length > 0 || quarantined.length > 0;
const testAgentsQuery = useQuery({
queryKey: queryKeys.tools.testAgents(connectionId),
queryFn: () => toolsApi.listTestAgents(connectionId),
enabled: !!connectionId && hasActions,
});
const agents = useMemo(
() => [...(testAgentsQuery.data?.agents ?? [])].sort(
(a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name),
),
[testAgentsQuery.data],
);
const [requestedAgentId, setRequestedAgentId] = useState<string | null>(null);
// The API returns only agents this user may write to. Prefer the highest
// agent in that accessible slice of the org tree, regardless of whether a
// lower-ranked agent happens to have a broader app policy today.
const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId)
? requestedAgentId
: agents[0]?.id ?? null;
const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null;
const testAgentAccessQuery = useQuery({
queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"),
queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!),
enabled: !!connectionId && !!agentId && hasActions,
staleTime: TEST_ACCESS_STALE_TIME_MS,
gcTime: TEST_ACCESS_GC_TIME_MS,
refetchOnWindowFocus: false,
});
const selectedAgent = useMemo<TestAgentWithAccess | null>(() => (
selectedAgentBase && testAgentAccessQuery.data
? { ...selectedAgentBase, effectiveAccess: testAgentAccessQuery.data.access }
: null
), [selectedAgentBase, testAgentAccessQuery.data]);
// Per-action decision for the selected agent, keyed by both the upstream and
// gateway tool names so we can match whatever the catalog stores.
const decisionByTool = useMemo(() => {
const map = new Map<string, ToolConnectionTestDecision>();
for (const tool of selectedAgent?.effectiveAccess.tools ?? []) {
map.set(tool.toolName, tool.decision);
map.set(tool.gatewayToolName, tool.decision);
}
return map;
}, [selectedAgent]);
const decisionFor = (entry: ToolCatalogEntry): ToolConnectionTestDecision =>
decisionByTool.get(entry.toolName) ?? "off";
// Search + read/write filter.
const [query, setQuery] = useState("");
const [kindFilter, setKindFilter] = useState<"all" | "read" | "write">("all");
const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) =>
(a.title ?? a.toolName).localeCompare(b.title ?? b.toolName);
const readActions = active.filter((e) => e.isReadOnly).sort(byName);
const writeActions = active.filter((e) => !e.isReadOnly).sort(byName);
const matches = (entry: ToolCatalogEntry) => {
if (kindFilter === "read" && !entry.isReadOnly) return false;
if (kindFilter === "write" && entry.isReadOnly) return false;
const needle = query.trim().toLowerCase();
if (!needle) return true;
return (
(entry.title ?? entry.toolName).toLowerCase().includes(needle) ||
(entry.description ?? "").toLowerCase().includes(needle)
);
};
const quarantinedActions = [...quarantined].sort(byName);
const visibleRead = readActions.filter(matches);
const visibleWrite = writeActions.filter(matches);
const visibleQuarantined = quarantinedActions.filter(matches);
const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length;
if (!hasActions) {
return <EmptyState connectionId={connectionId} appName={appName} />;
}
if (testAgentsQuery.isLoading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agents…
</div>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
if (testAgentsQuery.isError) {
return (
<TestLoadError
message="We couldn't load the agents available for testing."
onRetry={() => { void testAgentsQuery.refetch(); }}
/>
);
}
if (agents.length === 0) {
return (
<div className="py-6 text-center">
<p className="text-sm font-medium text-foreground">No agents to test as</p>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
<Link className="font-medium text-primary hover:underline" to={appTabHref(connectionId, "permissions")}>
Permissions
</Link>{" "}
to test it here.
</p>
</div>
);
}
if (testAgentAccessQuery.isError && !testAgentAccessQuery.data) {
return (
<TestLoadError
message={`We couldn't load ${selectedAgentBase?.name ?? "this agent"}'s permissions.`}
onRetry={() => { void testAgentAccessQuery.refetch(); }}
/>
);
}
if (testAgentAccessQuery.isLoading || !selectedAgent) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agent permissions…
</div>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
const sharedRowProps = {
connectionId,
appName,
allAgents: agents,
onSelectAgent: setRequestedAgentId,
};
return (
<div className="space-y-8">
{selectedAgent && (
<TestAsHeader
appName={appName}
agents={agents}
selectedAgent={selectedAgent}
onSelect={setRequestedAgentId}
connectionId={connectionId}
/>
)}
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-(--sz-12rem) flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Find an action"
placeholder="Find an action…"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<FilterChip label={`All ${active.length + quarantinedActions.length}`} active={kindFilter === "all"} onClick={() => setKindFilter("all")} />
<FilterChip label={`Read ${readActions.length}`} active={kindFilter === "read"} onClick={() => setKindFilter("read")} />
<FilterChip label={`Write ${writeActions.length}`} active={kindFilter === "write"} onClick={() => setKindFilter("write")} />
</div>
<p className="text-xs text-muted-foreground">{visibleCount} matches · sorted A–Z</p>
</section>
{visibleCount === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No actions match “{query}”. Clear the search to see them all.
</div>
) : (
<div className="space-y-6">
{visibleRead.length > 0 && selectedAgent && (
<ActionGroup
heading={`Read (${visibleRead.length})`}
entries={visibleRead}
decisionFor={decisionFor}
agent={selectedAgent}
{...sharedRowProps}
/>
)}
{visibleWrite.length > 0 && selectedAgent && (
<ActionGroup
heading={`Write (${visibleWrite.length})`}
entries={visibleWrite}
decisionFor={decisionFor}
agent={selectedAgent}
{...sharedRowProps}
/>
)}
{visibleQuarantined.length > 0 && selectedAgent && (
<ActionGroup
heading={`New (${visibleQuarantined.length})`}
subheading="New actions wait, switched off, until you turn them on."
entries={visibleQuarantined}
decisionFor={() => "off" as const}
agent={selectedAgent}
{...sharedRowProps}
/>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
return (
<div className="py-8 text-center">
<p className="text-base font-bold text-foreground">Nothing to test yet</p>
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">
Once {appName} is connected, the actions it offers will show up here so you can try them out.
</p>
<Button asChild className="mt-4" variant="outline">
<Link to={appTabHref(connectionId, "permissions")}>Go to Permissions</Link>
</Button>
</div>
);
}
function TestLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="py-8 text-center">
@@ -504,66 +230,21 @@ function TestLoadError({ message, onRetry }: { message: string; onRetry: () => v
}
// ---------------------------------------------------------------------------
// Test-as header + agent picker
// Agent picker
// ---------------------------------------------------------------------------
function TestAsHeader({
appName,
agents,
selectedAgent,
onSelect,
connectionId,
}: {
appName: string;
agents: ToolConnectionTestAgent[];
selectedAgent: TestAgentWithAccess;
onSelect: (agentId: string) => void;
connectionId: string;
}) {
return (
<section className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Test an action</h2>
<p className="mt-1 text-sm text-muted-foreground">
Run a real action as an agent.
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">Agent</p>
<AgentPicker
agents={agents}
selectedAgent={selectedAgent}
onSelect={onSelect}
connectionId={connectionId}
appName={appName}
/>
</div>
<Link
className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
to={appTabHref(connectionId, "permissions")}
>
{formatActionPermissionSummary(selectedAgent.effectiveAccess)}
</Link>
</div>
</section>
);
}
function AgentPicker({
agents,
selectedAgent,
onSelect,
connectionId,
appName,
inline,
}: {
agents: ToolConnectionTestAgent[];
selectedAgent: ToolConnectionTestAgent;
onSelect: (agentId: string) => void;
connectionId: string;
appName: string;
inline?: boolean;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -577,17 +258,14 @@ function AgentPicker({
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"items-center gap-1.5 text-foreground outline-none hover:text-primary focus-visible:text-primary",
inline ? "inline-flex font-semibold underline-offset-2 hover:underline" : "mt-0.5 flex text-lg font-bold",
)}
className="inline-flex items-center gap-1.5 font-semibold text-foreground underline-offset-2 outline-none hover:text-primary hover:underline focus-visible:text-primary"
aria-label="Choose which agent to test as"
>
{selectedAgent.name}
<ChevronsUpDown className={cn("text-muted-foreground", inline ? "h-3.5 w-3.5" : "h-4 w-4")} />
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0" disablePortal={inline}>
<PopoverContent align="start" className="w-80 p-0" disablePortal>
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -661,114 +339,6 @@ function AgentPicker({
);
}
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// Action group + rows
// ---------------------------------------------------------------------------
type RowSharedProps = {
connectionId: string;
appName: string;
allAgents: ToolConnectionTestAgent[];
onSelectAgent: (agentId: string) => void;
};
function ActionGroup({
heading,
subheading,
entries,
decisionFor,
agent,
...shared
}: {
heading: string;
subheading?: string;
entries: ToolCatalogEntry[];
decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision;
agent: TestAgentWithAccess;
} & RowSharedProps) {
return (
<section>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h3>
{subheading && <p className="mb-1.5 -mt-1 text-xs text-muted-foreground">{subheading}</p>}
<div className="divide-y divide-border">
{entries.map((entry) => (
<ActionRow
key={entry.id}
entry={entry}
decision={decisionFor(entry)}
agent={agent}
{...shared}
/>
))}
</div>
</section>
);
}
function ActionRow({
entry,
decision,
agent,
...shared
}: {
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
agent: TestAgentWithAccess;
} & RowSharedProps) {
const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent)));
const title = entry.title ?? entry.toolName;
const sub = actionSubLine(entry);
useEffect(() => {
if (loadStoredAskFirstOutcome(shared.connectionId, entry, agent)) {
setOpen(true);
}
}, [shared.connectionId, entry, agent]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left outline-none hover:bg-accent/40 focus-visible:bg-accent/40"
>
<ChevronDown
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{title}</span>
{sub && <span className="block truncate text-xs text-muted-foreground">{sub}</span>}
</span>
<DecisionBadge decision={decision} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border py-4 pl-11">
<ActionTester entry={entry} decision={decision} agent={agent} {...shared} />
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ---------------------------------------------------------------------------
// The actual tester (form + run + result)
// ---------------------------------------------------------------------------
@@ -854,7 +424,11 @@ function ActionTester({
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
agent: TestAgentWithAccess;
} & RowSharedProps) {
connectionId: string;
appName: string;
allAgents: ToolConnectionTestAgent[];
onSelectAgent: (agentId: string) => void;
}) {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const rawSchema = (entry.inputSchema ?? { type: "object", properties: {} }) as JsonSchemaNode;
@@ -1202,6 +776,59 @@ function isEmptyResult(value: unknown): boolean {
return false;
}
/** The gateway keeps the original MCP blocks inside `data`; the top-level
* `content` is only a flattened convenience string. Prefer structured output,
* then parse individual text blocks when they happen to contain JSON. */
function resultPreview(value: unknown): { items: Array<{ label: string | null; value: unknown }>; summary: unknown; rawFallback?: boolean } {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { items: [{ label: null, value }], summary: value };
}
const wrapper = value as Record<string, unknown>;
const data = wrapper.data && typeof wrapper.data === "object" && !Array.isArray(wrapper.data)
? wrapper.data as Record<string, unknown>
: null;
const result = data && (Array.isArray(data.content) || data.structuredContent != null) ? data : wrapper;
const blocks = Array.isArray(result.content) ? result.content : null;
if (!blocks && result.structuredContent == null) {
return { items: [{ label: null, value }], summary: value };
}
const parseText = (text: string): unknown => {
try { return JSON.parse(text); } catch { return text; }
};
const structured = result.structuredContent;
const items: Array<{ label: string | null; value: unknown }> = [];
const structuredValue = typeof structured === "string" ? parseText(structured) : structured;
if (structured !== null && structured !== undefined) {
items.push({ label: null, value: structuredValue });
}
for (const block of blocks ?? []) {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return { items: [{ label: null, value }], summary: value, rawFallback: true };
}
const content = block as Record<string, unknown>;
if (content.type === "text" && typeof content.text === "string") {
const parsed = parseText(content.text);
if (structured === null || structured === undefined || safeStringify(parsed) !== safeStringify(structuredValue)) {
items.push({ label: (blocks?.length ?? 0) > 1 || (structured !== null && structured !== undefined) ? "Text" : null, value: parsed });
}
} else if (content.type === "image" || content.type === "audio") {
items.push({ label: content.type === "image" ? "Image" : "Audio", value: typeof content.mimeType === "string" ? content.mimeType : "Media attachment" });
} else if (content.type === "resource_link") {
items.push({ label: "Resource", value: content.title ?? content.name ?? content.uri ?? "Resource link" });
} else if (content.type === "resource" && content.resource && typeof content.resource === "object") {
const resource = content.resource as Record<string, unknown>;
items.push({ label: "Resource", value: typeof resource.text === "string" ? parseText(resource.text) : resource.uri ?? "Embedded resource" });
} else {
return { items: [{ label: null, value }], summary: value, rawFallback: true };
}
}
if (items.length === 0 && typeof wrapper.content === "string") {
items.push({ label: null, value: parseText(wrapper.content) });
}
return { items, summary: structured !== null && structured !== undefined ? structuredValue : items[0]?.value };
}
function writeVerb(entry: ToolCatalogEntry): string | null {
const n = `${entry.toolName} ${entry.title ?? ""}`.toLowerCase();
if (/\b(append|add|insert|create|new)\b/.test(n)) return "added";
@@ -1231,27 +858,35 @@ function AllowedResult({
connectionId: string;
}) {
const value = outcome.result.result;
const preview = resultPreview(value);
return (
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-foreground">{successHeadline(value, entry, appName)}</span>
<span className="text-sm font-medium text-foreground">{successHeadline(preview.summary, entry, appName)}</span>
</div>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Ran as {outcome.agentName} · {seconds(outcome.durationMs)} · {relTime(outcome.ranAt)}
</p>
{!isEmptyResult(value) && (
{preview.items.some((item) => !isEmptyResult(item.value)) && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Preview</p>
<div className="mt-1.5">
<PrettyPreview value={value} />
<div className="space-y-2">
{preview.items.map((item, index) => (
<div key={index}>
{item.label && <p className="mb-1 text-xs font-medium text-muted-foreground">{item.label}</p>}
<PrettyPreview value={item.value} />
</div>
))}
</div>
</div>
</div>
)}
<RawResponseDisclosure value={value} />
<RawResponseDisclosure value={value} initiallyOpen={preview.rawFallback} />
<p className="mt-3 text-xs text-muted-foreground">
This call is in the{" "}
@@ -1265,12 +900,25 @@ function AllowedResult({
);
}
/** Pretty preview: table for row arrays, depth-limited JSON otherwise, plain text for strings. */
/** Pretty preview: tables for row arrays, labeled fields for objects, plain text for prose. */
function PrettyPreview({ value }: { value: unknown }) {
const rows = asRows(value);
if (rows) {
const columns = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))).slice(0, 6);
const shown = rows.slice(0, 6);
const needsCards = columns.length > 4 || shown.some((row) =>
columns.some((column) => cellText(row[column]).length > 80)
);
if (needsCards) {
return (
<div className="space-y-2">
{shown.map((row, index) => <PreviewRowCard key={index} row={row} index={index} />)}
{rows.length > shown.length && (
<p className="text-(length:--text-micro) text-muted-foreground">… {rows.length - shown.length} more rows in the raw response</p>
)}
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-left text-xs">
@@ -1300,6 +948,18 @@ function PrettyPreview({ value }: { value: unknown }) {
if (typeof value === "string") {
return <pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-3 text-xs text-foreground">{value}</pre>;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
return (
<dl className="max-h-64 overflow-auto divide-y divide-border rounded-md border border-border bg-background text-xs">
{Object.entries(value as Record<string, unknown>).map(([key, field]) => (
<div key={key} className="grid grid-cols-3 gap-3 px-3 py-2">
<dt className="min-w-0 break-words text-muted-foreground">{key}</dt>
<dd className="col-span-2 min-w-0 break-words whitespace-pre-wrap text-foreground">{typeof field === "object" && field !== null ? safeStringify(collapseDeep(field, 2)) : cellText(field)}</dd>
</div>
))}
</dl>
);
}
return (
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 text-xs text-foreground">
{safeStringify(collapseDeep(value, 2))}
@@ -1307,6 +967,42 @@ function PrettyPreview({ value }: { value: unknown }) {
);
}
function PreviewRowCard({ row, index }: { row: Record<string, unknown>; index: number }) {
const titleKey = ["title", "name", "label"].find((key) => typeof row[key] === "string" && row[key] !== "");
const summaryKey = ["highlight", "description", "summary"].find((key) => typeof row[key] === "string" && row[key] !== "");
const url = typeof row.url === "string" && /^https?:\/\//i.test(row.url) ? row.url : null;
const details = Object.entries(row)
.filter(([key]) => key !== titleKey && key !== summaryKey && (key !== "url" || !url))
.sort(([left], [right]) => {
const order = ["url", "type", "status", "timestamp", "id"];
const leftRank = order.indexOf(left);
const rightRank = order.indexOf(right);
return (leftRank < 0 ? order.length : leftRank) - (rightRank < 0 ? order.length : rightRank);
});
return (
<div className="min-w-0 rounded-md border border-border bg-background p-3 text-xs">
<div className="flex min-w-0 items-start justify-between gap-3">
<p className="min-w-0 break-words text-sm font-medium text-foreground">
{titleKey ? cellText(row[titleKey]) : `Result ${index + 1}`}
</p>
{url && <a className="shrink-0 text-primary hover:underline" href={url} target="_blank" rel="noopener noreferrer">Open link</a>}
</div>
{summaryKey && <p className="mt-1 line-clamp-2 break-words text-muted-foreground">{cellText(row[summaryKey]).replace(/\*\*(.*?)\*\*/g, "$1")}</p>}
{details.length > 0 && (
<dl className="mt-2 space-y-1">
{details.slice(0, 3).map(([key, field]) => (
<div key={key} className="grid grid-cols-3 gap-2">
<dt className="min-w-0 break-words text-muted-foreground">{key}</dt>
<dd className="col-span-2 min-w-0 break-all text-foreground">{cellText(field)}</dd>
</div>
))}
</dl>
)}
{details.length > 3 && <p className="mt-2 text-muted-foreground">{details.length - 3} more {details.length === 4 ? "field" : "fields"} in the raw response</p>}
</div>
);
}
function cellText(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return Array.isArray(value) ? `[${value.length}]` : "{…}";
@@ -1325,8 +1021,8 @@ function collapseDeep(value: unknown, maxDepth: number, depth = 0): unknown {
return out;
}
function RawResponseDisclosure({ value }: { value: unknown }) {
const [showRaw, setShowRaw] = useState(false);
function RawResponseDisclosure({ value, initiallyOpen = false }: { value: unknown; initiallyOpen?: boolean }) {
const [showRaw, setShowRaw] = useState(initiallyOpen);
if (value === undefined || value === null) return null;
return (
<div className="mt-3">
@@ -1384,6 +1080,7 @@ function ErrorResult({
))}
</ul>
</div>
{outcome.result.result !== undefined && <RawResponseDisclosure value={outcome.result.result} initiallyOpen />}
<p className="mt-3 text-xs text-muted-foreground">
{needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."}
</p>
@@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/utils";
import { type InstallState } from "@/lib/tool-installs";
import { QuarantinedActionsReview } from "./SetupPanel";
import { ActionTestDialog } from "./TestPanel";
import { ActionTestDialog } from "./ActionTestDialog";
import type { AccessDraft, AppDetailSectionProps } from "./types";
type ActionPermission = "off" | "ask" | "allowed";
@@ -1,575 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import { TestPanel, errorHints } from "./TestPanel";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) =>
getTestAgentAccessMock(connectionId, agentId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
getTestCallStatusMock(connectionId, actionRequestId),
declineActionRequest: (companyId: string, actionRequestId: string) =>
declineActionRequestMock(companyId, actionRequestId),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
/** The form field input — excludes the "Find an action" search box. */
function formInput(): HTMLInputElement {
const inputs = [...container.querySelectorAll("input")].filter(
(i) => i.getAttribute("aria-label") !== "Find an action",
);
return inputs[0] as HTMLInputElement;
}
async function fillFormField(value: string) {
const input = formInput();
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
await act(async () => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
}
/** Wait past the 200ms minimum-spinner delay so the result panel renders. */
async function settle() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 260));
});
await flushReact();
}
async function clickByText(text: string) {
const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text);
expect(btn).toBeTruthy();
await act(async () => btn!.click());
await flushReact();
}
function catalogEntry(overrides: Partial<ToolCatalogEntry> = {}): ToolCatalogEntry {
return {
id: "catalog-read",
companyId: "company-1",
applicationId: "app-1",
connectionId: "conn-1",
entryKind: "tool",
toolName: "read_sheet",
title: "Read a sheet",
description: "Get rows and cell values from a sheet.",
inputSchema: {
type: "object",
properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } },
required: ["spreadsheetId"],
},
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-01-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-01-01T00:00:00Z"),
lastSeenAt: new Date("2026-01-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
} as ToolCatalogEntry;
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-claude",
name: "ClaudeCoder",
role: "engineer",
title: "Engineer",
status: "active",
orgDepth: 1,
effectiveAccess: {
connectionId: "conn-1",
toolCount: 3,
allowedCount: 1,
askFirstCount: 1,
offCount: 1,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools: [
{ toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] },
{ toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] },
{ toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] },
],
},
...overrides,
};
}
const readEntry = catalogEntry();
const writeAskEntry = catalogEntry({
id: "catalog-append",
toolName: "append_row",
title: "Append a row",
description: "Add a new row to the bottom of a sheet.",
isReadOnly: false,
isWrite: true,
riskLevel: "write",
});
const offEntry = catalogEntry({
id: "catalog-delete",
toolName: "delete_row",
title: "Delete a row",
description: "Remove a row from a sheet permanently.",
isReadOnly: false,
isWrite: true,
isDestructive: true,
riskLevel: "destructive",
});
let container: HTMLDivElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let root: any;
let client: QueryClient;
function renderPanel(
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
quarantined: ToolCatalogEntry[] = [],
) {
root.render(
<QueryClientProvider client={client}>
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
</QueryClientProvider>,
);
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listTestAgentsMock.mockReset();
getTestAgentAccessMock.mockReset();
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset();
declineActionRequestMock.mockReset();
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess });
// Default ask-first polls report the request still waiting on approval.
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "waiting",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: null,
durationMs: null,
});
window.sessionStorage.clear();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
window.sessionStorage.clear();
});
describe("TestPanel", () => {
it("shows a lightweight agent-loading state before requesting permissions", async () => {
listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
await act(async () => renderPanel());
expect(container.textContent).toContain("Loading agents…");
expect(container.querySelector(".animate-spin")).toBeTruthy();
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
expect(getTestAgentAccessMock).not.toHaveBeenCalled();
});
it("loads permissions only for the selected agent and reuses the cached summary", async () => {
await act(async () => renderPanel());
await flushReact();
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude");
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => renderPanel());
await flushReact();
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
});
it("renders the test hierarchy and grouped actions with access badges", async () => {
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Test an action");
expect(container.textContent).toContain("Agent");
expect(container.textContent).toContain("ClaudeCoder");
expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
expect(container.textContent).toContain("Read (1)");
expect(container.textContent).toContain("Write (2)");
// Each action shows its decision badge.
expect(container.textContent).toContain("Allowed");
expect(container.textContent).toContain("Ask first");
expect(container.textContent).toContain("Off");
expect(container.querySelector(".bg-card")).toBeNull();
});
it("defaults to the highest-ranked accessible agent", async () => {
listTestAgentsMock.mockResolvedValue({
agents: [
agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
],
});
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Root agent");
expect(container.textContent).not.toContain("A report");
});
it("shows the empty state when there are no actions", async () => {
await act(async () => renderPanel([]));
await flushReact();
expect(container.textContent).toContain("Nothing to test yet");
expect(container.textContent).toContain("Go to Permissions");
});
it("renders an allowed result panel with a row-count headline after a successful run", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-1",
result: [
{ name: "Acme", stage: "Demo" },
{ name: "Globex", stage: "Trial" },
],
});
await act(async () => renderPanel());
await flushReact();
// Expand the allowed (read) action.
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
expect(trigger).toBeTruthy();
await act(async () => trigger!.click());
await flushReact();
// Fill the required field then Run.
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
agentId: "agent-claude",
toolName: "read_sheet",
parameters: { spreadsheetId: "sheet-123" },
});
expect(container.textContent).toContain("Worked. 2 rows came back.");
expect(container.textContent).toContain("Ran as ClaudeCoder");
expect(container.textContent).toContain("Preview");
});
it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => {
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-credential",
error: { message: "The connection must be reconnected.", reasonCode },
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("After reconnecting, run this action again.");
expect(container.textContent).not.toContain("Adjust the input above");
expect(container.textContent).not.toContain("Check the field formats");
expect(container.textContent).toContain(reasonCode === "grant_credential_invalid"
? "Ask the connection owner to reconnect"
: "allow the permissions required for this action");
});
it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
// The gateway let the call through (decision:"allowed") but the upstream MCP
// tool failed at the tool layer — the envelope carries isError + an error.
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-err",
result: {
content: "Invalid arguments: range is required",
data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true },
error: "MCP tool returned an error result",
},
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("It didn't work.");
expect(container.textContent).not.toContain("Worked.");
// The actual MCP message wins over the generic gateway wrapper string.
expect(container.textContent).toContain("Invalid arguments: range is required");
});
it("renders the ask-first card linking to Review", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Sent for your OK.");
expect(container.textContent).toContain("Cancel this request");
// "Where" is populated from the polled action-request snapshot.
expect(container.textContent).toContain("spreadsheetId: sheet-123");
expect(container.textContent).toContain("Waiting ·");
const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab"));
expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review");
});
it("restores the ask-first card after leaving and returning to the Test tab", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Sent for your OK.");
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => renderPanel());
await flushReact();
expect(runTestCallMock).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Sent for your OK.");
expect(container.textContent).toContain("spreadsheetId: sheet-123");
expect(container.textContent).toContain("Cancel this request");
});
it("shows 'Approved · running' while an approved ask-first call is executing", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "running",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: null,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Approved · running");
// While running we don't offer cancel — the call is already on its way.
expect(container.textContent).not.toContain("Cancel this request");
});
it("mutates the ask-first card into the allowed result once approved and done", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "done",
parameters: { spreadsheetId: "sheet-123" },
result: [{ name: "Acme", stage: "Demo" }],
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: 1200,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
// No longer the pending card — the real response is shown without re-running.
expect(container.textContent).not.toContain("Sent for your OK.");
expect(container.textContent).toContain("Worked.");
expect(container.textContent).toContain("1.2s");
});
it("shows the denied status when an ask-first call is declined in Review", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "denied",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: null,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Denied — see Review for why");
expect(container.textContent).not.toContain("Cancel this request");
});
it("explains an off action and links to Permissions without calling the API", async () => {
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("Delete a row is off for ClaudeCoder.");
const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions");
expect(permLink).toBeTruthy();
// No Run button is offered for an off action.
expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false);
expect(runTestCallMock).not.toHaveBeenCalled();
});
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
getTestAgentAccessMock.mockResolvedValue({
access: {
...agent().effectiveAccess,
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("Last changed by Dotta");
});
it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => {
const quarantinedEntry = catalogEntry({
id: "catalog-new",
toolName: "rename_sheet",
title: "Rename a sheet",
description: "Change a sheet's name.",
isReadOnly: false,
isWrite: true,
riskLevel: "write",
status: "quarantined",
});
await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry]));
await flushReact();
expect(container.textContent).toContain("New (1)");
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet"));
expect(trigger).toBeTruthy();
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("This action is new and hasn't been turned on yet.");
expect(runTestCallMock).not.toHaveBeenCalled();
});
});
describe("errorHints", () => {
it("maps known reason codes to tailored hints", () => {
expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0);
expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission");
expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0);
expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit");
});
it("returns the locked generic fallback for an unknown error", () => {
expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]);
});
});
@@ -24,36 +24,48 @@ describe("GitHub setup prompt", () => {
container.remove();
});
it("copies the complete instructions and confirms success", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
async function openPrompt() {
await act(async () => container.querySelector("button")!.click());
return document.querySelector('[role="dialog"]')!;
}
async function copyPrompt(dialog: Element) {
await act(async () => dialog.querySelector<HTMLButtonElement>(".agent-setup-copy")!.click());
}
it("copies on the opening click and confirms success in both buttons", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildGitHubSetupPrompt(window.location.origin));
expect(vi.mocked(copyTextToClipboard).mock.calls[0][0]).toContain(`Paperclip instance URL: ${window.location.origin}`);
expect(container.querySelector("button")?.textContent).toBe("Copied setup prompt");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Paste it into Codex or Claude");
expect(container.querySelector("textarea")).toBeNull();
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Ready to paste into your agent.");
expect(container.querySelector("button")?.dataset.copied).toBe("true");
expect(dialog.querySelector("textarea")).toBeNull();
});
it("offers selectable instructions when clipboard access fails and allows retry", async () => {
vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable"));
await act(async () => container.querySelector("button")!.click());
const fallback = container.querySelector("textarea")!;
const dialog = await openPrompt();
const fallback = dialog.querySelector("textarea")!;
expect(fallback.value).toBe(buildGitHubSetupPrompt(window.location.origin));
expect(fallback.readOnly).toBe(true);
fallback.focus();
expect(fallback.selectionStart).toBe(0);
expect(fallback.selectionEnd).toBe(fallback.value.length);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
expect(dialog.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
vi.mocked(copyTextToClipboard).mockResolvedValueOnce(undefined);
await act(async () => container.querySelector("button")!.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
await copyPrompt(dialog);
expect(dialog.querySelector('[role="alert"]')).toBeNull();
expect(dialog.querySelector("textarea")).toBeNull();
});
it("uses the preview's configured instance instead of its Storybook host", async () => {
await act(async () => root.render(<GitHubSetupPrompt instanceUrl="https://my-company.paperclip.app" />));
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
await act(async () => container.querySelector("button")!.click());
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildGitHubSetupPrompt("https://my-company.paperclip.app"));
});
+1 -1
View File
@@ -43,5 +43,5 @@ export function buildGitHubSetupPrompt(instanceUrl: string) {
}
export function GitHubSetupPrompt({ instanceUrl = window.location.origin }: { instanceUrl?: string }) {
return <SetupPrompt prompt={buildGitHubSetupPrompt(instanceUrl)} />;
return <SetupPrompt prompt={buildGitHubSetupPrompt(instanceUrl)} description="Paste this into your agent to configure your GitHub review bot." />;
}
+1 -40
View File
@@ -1,8 +1,4 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard } from "@/lib/clipboard";
export { AgentSetupPrompt as SetupPrompt } from "@/components/AgentSetupPrompt";
export function buildSetupPrompt(instanceUrl: string, instructions: string) {
let instanceOrigin: string | null = null;
@@ -17,38 +13,3 @@ export function buildSetupPrompt(instanceUrl: string, instructions: string) {
: "Paperclip instance URL is unavailable. Ask me for it before starting setup.";
return `${context}\n\n${instructions}`;
}
export function SetupPrompt({ prompt }: { prompt: string }) {
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
return (
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="gap-2 border-dashed text-muted-foreground"
onClick={async () => {
try {
await copyTextToClipboard(prompt);
setStatus("copied");
} catch {
setStatus("failed");
}
}}
>
<span className="flex -space-x-1" aria-hidden="true">
<img src="/brands/claude-color.svg" alt="" className="size-4 rounded-full bg-background ring-2 ring-background" />
<img src="/brands/codex-color.svg" alt="" className="size-4 rounded-full bg-background ring-2 ring-background" />
</span>
{status === "copied" ? "Copied setup prompt" : "Copy setup prompt"}
{status === "copied" ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
<span className="sr-only" role="status">{status === "copied" ? "Setup prompt copied. Paste it into Codex or Claude with browser tools." : ""}</span>
{status === "failed" && (
<div className="space-y-2">
<p role="alert" className="text-sm text-muted-foreground">Could not copy automatically. Select and copy the setup prompt below.</p>
<Textarea aria-label="Setup prompt" readOnly value={prompt} onFocus={(event) => event.currentTarget.select()} rows={8} />
</div>
)}
</div>
);
}
@@ -24,39 +24,93 @@ describe("Slack setup prompt", () => {
container.remove();
});
it("copies the complete instructions and confirms success", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
async function openPrompt() {
await act(async () => container.querySelector("button")!.click());
return document.querySelector('[role="dialog"]')!;
}
async function copyPrompt(dialog: Element) {
await act(async () => dialog.querySelector<HTMLButtonElement>(".agent-setup-copy")!.click());
}
it("copies on the opening click and confirms success in both buttons", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildSlackSetupPrompt(window.location.origin));
expect(vi.mocked(copyTextToClipboard).mock.calls[0][0]).toContain(`Paperclip instance URL: ${window.location.origin}`);
expect(container.querySelector("button")?.textContent).toBe("Copied setup prompt");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Paste it into Codex or Claude");
expect(container.querySelector("textarea")).toBeNull();
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Ready to paste into your agent.");
expect(container.querySelector("button")?.dataset.copied).toBe("true");
expect(dialog.querySelector("textarea")).toBeNull();
});
it("offers selectable instructions when clipboard access fails and allows retry", async () => {
vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable"));
await act(async () => container.querySelector("button")!.click());
const fallback = container.querySelector("textarea")!;
vi.mocked(copyTextToClipboard).mockImplementationOnce(async () => {
// The browser fallback focuses an off-screen textarea outside the popover.
const clipboardField = document.createElement("textarea");
document.body.append(clipboardField);
clipboardField.focus();
clipboardField.remove();
throw new Error("Clipboard unavailable");
});
const dialog = await openPrompt();
const fallback = dialog.querySelector("textarea")!;
expect(fallback.value).toBe(buildSlackSetupPrompt(window.location.origin));
expect(fallback.readOnly).toBe(true);
fallback.focus();
expect(fallback.selectionStart).toBe(0);
expect(fallback.selectionEnd).toBe(fallback.value.length);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
expect(dialog.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
vi.mocked(copyTextToClipboard).mockResolvedValueOnce(undefined);
await act(async () => container.querySelector("button")!.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
await copyPrompt(dialog);
expect(dialog.querySelector('[role="alert"]')).toBeNull();
expect(dialog.querySelector("textarea")).toBeNull();
});
it("uses the preview's configured instance instead of its Storybook host", async () => {
await act(async () => root.render(<SlackSetupPrompt instanceUrl="https://my-company.paperclip.app" />));
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
await act(async () => container.querySelector("button")!.click());
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildSlackSetupPrompt("https://my-company.paperclip.app"));
});
it("ignores stale copy results when the prompt changes", async () => {
let finishCopy!: () => void;
vi.mocked(copyTextToClipboard).mockImplementationOnce(() => new Promise<void>((resolve) => { finishCopy = resolve; }));
const dialog = await openPrompt();
await copyPrompt(dialog);
await copyPrompt(dialog);
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copying…");
await act(async () => root.render(<SlackSetupPrompt instanceUrl="https://another.paperclip.app" />));
await act(async () => finishCopy());
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copy prompt");
expect(dialog.querySelector("pre")?.textContent).toContain("https://another.paperclip.app");
});
it("copies again on every opening", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
await act(async () => dialog.querySelector<HTMLButtonElement>('[aria-label="Close agent setup"]')!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
await act(async () => container.querySelector("button")!.click());
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(copyTextToClipboard).toHaveBeenCalledTimes(2);
});
it("keeps the preview open when copying again from the trigger and does not copy on dismissal", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
await act(async () => container.querySelector("button")!.click());
expect(document.querySelector('[role="dialog"]')).toBe(dialog);
expect(copyTextToClipboard).toHaveBeenCalledTimes(2);
await act(async () => dialog.querySelector<HTMLButtonElement>('[aria-label="Close agent setup"]')!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(copyTextToClipboard).toHaveBeenCalledTimes(2);
});
it("includes only the instance origin, without URL credentials or callback state", () => {
const prompt = buildSlackSetupPrompt("https://user:private-password@my-company.paperclip.app/GIT/apps/chat/connect?code=private-code#private-state");
expect(prompt).toContain("Paperclip instance URL: https://my-company.paperclip.app\n");
+1 -1
View File
@@ -38,5 +38,5 @@ export function buildSlackSetupPrompt(instanceUrl: string) {
}
export function SlackSetupPrompt({ instanceUrl = window.location.origin }: { instanceUrl?: string }) {
return <SetupPrompt prompt={buildSlackSetupPrompt(instanceUrl)} />;
return <SetupPrompt prompt={buildSlackSetupPrompt(instanceUrl)} description="Paste this into your agent to configure your Slack bot." />;
}
@@ -126,13 +126,14 @@ describe("Paste a config — MCP config help", () => {
await openHelp();
await act(async () => {
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonWithText("Get a config with an agent")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(copyTextToClipboardMock).toHaveBeenCalledWith(MCP_CONFIG_HELP_PROMPT);
expect(document.body.textContent).toContain("Copied to clipboard.");
expect(copyTextToClipboardMock).toHaveBeenCalledExactlyOnceWith(MCP_CONFIG_HELP_PROMPT);
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(MCP_CONFIG_HELP_PROMPT);
expect(document.body.textContent).toContain("Copied to clipboard");
});
it("tells the operator to copy by hand when the clipboard is unavailable", async () => {
@@ -141,14 +142,15 @@ describe("Paste a config — MCP config help", () => {
await openHelp();
await act(async () => {
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonWithText("Get a config with an agent")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
await vi.waitFor(() => {
expect(document.body.textContent).toContain("select the text above and copy it");
expect(document.body.textContent).toContain("Select and copy the prompt above");
});
expect(document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Setup prompt"]')?.value).toBe(MCP_CONFIG_HELP_PROMPT);
});
it("makes no connection or import request when opened or copied", async () => {
@@ -163,7 +165,7 @@ describe("Paste a config — MCP config help", () => {
await openHelp();
await act(async () => {
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonWithText("Get a config with an agent")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
+10 -45
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy, HelpCircle } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -10,11 +10,8 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard } from "@/lib/clipboard";
import { MCP_CONFIG_HELP_INSTRUCTIONS, MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
const COPIED_RESET_MS = 2_000;
/**
* Compact question-mark help beside the Paste-a-config copy (PAP-17087, plan 3A).
*
@@ -24,33 +21,8 @@ const COPIED_RESET_MS = 2_000;
* anything the operator has typed.
*/
export function McpConfigHelpDialog() {
const [open, setOpen] = useState(false);
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
}, []);
// Reset the copy affordance each time the dialog opens, so a stale "Copied"
// from a previous visit never reads as confirmation of this one.
useEffect(() => {
if (open) setCopyState("idle");
}, [open]);
const copyPrompt = async () => {
try {
await copyTextToClipboard(MCP_CONFIG_HELP_PROMPT);
setCopyState("copied");
} catch {
setCopyState("failed");
}
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopyState("idle"), COPIED_RESET_MS);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
@@ -92,20 +64,13 @@ export function McpConfigHelpDialog() {
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" onClick={() => void copyPrompt()}>
{copyState === "copied" ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
Copy prompt
</Button>
{/* aria-live so a screen reader hears the outcome without moving focus
off the button the operator just pressed. */}
<span aria-live="polite" className="text-xs text-muted-foreground">
{copyState === "copied"
? "Copied to clipboard."
: copyState === "failed"
? "Couldn't copy automatically — select the text above and copy it."
: null}
</span>
<div>
<AgentSetupPrompt
prompt={MCP_CONFIG_HELP_PROMPT}
label="Get a config with an agent"
title="MCP configuration"
description="Paste this into your agent to create an MCP config, then paste its JSON reply back into Paperclip."
/>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,243 @@
import { useEffect, useMemo } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ToolCatalogEntry, ToolConnectionTestCallResult } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
const CONNECTION = "conn-asana-storybook";
const TOOL: ToolCatalogEntry = {
id: "asana-list-projects",
companyId: "company-storybook",
applicationId: "app-asana",
connectionId: CONNECTION,
entryKind: "tool",
toolName: "list_projects",
title: "List projects",
description: "Find projects available in this Asana workspace.",
inputSchema: { type: "object", properties: {} },
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-09-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-09-01T00:00:00Z"),
lastSeenAt: new Date("2026-10-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-09-01T00:00:00Z"),
updatedAt: new Date("2026-10-01T00:00:00Z"),
} as ToolCatalogEntry;
const PROJECTS = [
{ gid: "1214644669600482", name: "Q4 launch", resource_subtype: "default_project", team: "Marketing" },
{ gid: "1214644669600483", name: "Customer onboarding", resource_subtype: "default_project", team: "Product" },
{ gid: "1214644669600484", name: "Support operations", resource_subtype: "default_project", team: "Operations" },
];
const NOTION_TOOL = {
...TOOL,
id: "notion-search",
applicationId: "app-notion",
toolName: "search",
title: "Search Notion",
description: "Search pages in your Notion workspace.",
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
} as ToolCatalogEntry;
const NOTION_RESULTS = [
{
id: "11111111-2222-4333-8444-555555555555",
title: "Northstar launch plan",
url: "https://www.notion.so/11111111222243338444555555555555",
type: "page",
highlight: "The team is tracking launch milestones, owners, dependencies, and the decisions needed before the release window.",
timestamp: "2026-09-10T14:30:00.000Z",
},
{
id: "66666666-7777-4888-8999-aaaaaaaaaaaa",
title: "Launch customer research",
url: "https://www.notion.so/66666666777748888999aaaaaaaaaaaa",
type: "page",
highlight: "Interviews with early customers point to a clearer onboarding flow and better examples for complex team setups.",
timestamp: "2026-09-08T09:15:00.000Z",
},
];
function mcpResult(content: Array<Record<string, unknown>>, structuredContent: unknown = null, isError = false) {
return {
content: content.map((block) => block.type === "text" ? block.text : JSON.stringify(block)).join("\n"),
data: { content, structuredContent, isError, transport: "mcp_http", spawnedLocalProcess: false },
...(isError ? { error: "MCP tool returned an error result" } : {}),
};
}
function seededClient(tool: ToolCatalogEntry) {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
});
client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, "agent-ceo"), {
access: {
connectionId: CONNECTION,
toolCount: 1,
allowedCount: 1,
askFirstCount: 0,
offCount: 0,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools: [{
toolName: tool.toolName,
gatewayToolName: `${tool.applicationId}__${tool.toolName}`,
displayName: tool.title,
risk: "read",
decision: "allowed",
reasonCode: null,
matchedPolicyIds: [],
}],
},
});
return client;
}
function openAndRun(query?: string) {
let step = 0;
const tick = (attempt: number) => {
if (step === 0) {
const button = [...document.querySelectorAll("button")].find((item) => item.textContent?.trim() === "Test");
if (button) { button.click(); step = 1; }
} else {
const dialog = document.querySelector('[role="dialog"]');
if (query && step === 1) {
const input = dialog?.querySelector('input[placeholder=""]') as HTMLInputElement | null;
if (input) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, query);
input.dispatchEvent(new Event("input", { bubbles: true }));
step = 2;
}
}
const run = [...(dialog?.querySelectorAll("button") ?? [])].find((item) => item.textContent?.trim() === "Run");
if (run && (!query || step === 2)) { run.click(); return; }
}
if (attempt < 80) window.setTimeout(() => tick(attempt + 1), 50);
};
tick(0);
}
function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }: {
result: ToolConnectionTestCallResult;
tool?: ToolCatalogEntry;
appName?: string;
query?: string;
}) {
const client = useMemo(() => seededClient(tool), [tool]);
useEffect(() => {
const original = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.includes(`/tool-connections/${CONNECTION}/test-calls`)) {
await new Promise((resolve) => window.setTimeout(resolve, 120));
return Response.json(result);
}
return original(input, init);
};
const timer = window.setTimeout(() => openAndRun(query), 150);
return () => { window.clearTimeout(timer); window.fetch = original; };
}, [query, result]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-3xl p-6">
<ActionsSection
connectionId={CONNECTION}
appName={appName}
readOnly={[tool]}
canChange={[]}
quarantined={[]}
enabledIds={new Set([tool.id])}
askFirstIds={new Set()}
disabled={false}
refreshPending={false}
canConfigure
onSetPermission={() => undefined}
onReviewQuarantined={() => undefined}
onRefreshActions={() => undefined}
/>
</div>
</QueryClientProvider>
);
}
const meta = {
title: "Apps/Action permissions test",
parameters: { layout: "fullscreen" },
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const StructuredRows: Story = {
name: "Structured rows",
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "structured", result: mcpResult(
[{ type: "text", text: JSON.stringify({ data: PROJECTS }) }],
{ data: PROJECTS },
) }} />,
};
export const StructuredOnly: Story = {
name: "Structured data without content blocks",
render: () => <PermissionsTestStory result={{
decision: "allowed",
invocationId: "structured-only",
result: { content: "", data: { structuredContent: { data: PROJECTS }, isError: false } },
}} />,
};
export const JsonInText: Story = {
name: "JSON inside text",
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "json-text", result: mcpResult(
[{ type: "text", text: JSON.stringify({ data: PROJECTS }) }],
) }} />,
};
export const NotionWideSearch: Story = {
name: "Notion search with long fields",
render: () => <PermissionsTestStory tool={NOTION_TOOL} appName="Notion" query="launch" result={{
decision: "allowed", invocationId: "notion-search", result: mcpResult(
[{ type: "text", text: JSON.stringify({ results: NOTION_RESULTS }) }],
),
}} />,
};
export const MixedBlocks: Story = {
name: "Mixed content blocks",
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "mixed", result: mcpResult(
[
{ type: "text", text: "Three active projects are visible in this workspace." },
{ type: "resource_link", uri: "asana://projects/q4-launch", name: "Q4 launch" },
{ type: "image", mimeType: "image/png", data: "cHJldmlldw==" },
],
{ data: PROJECTS },
) }} />,
};
export const ToolError: Story = {
name: "Tool error with raw response",
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "error", result: mcpResult(
[{ type: "text", text: "Asana denied access to this workspace." }], null, true,
) }} />,
};
export const MalformedBlock: Story = {
name: "Unexpected block with raw fallback",
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "malformed", result: mcpResult(
[{ type: "text", payload: "Missing the required text field" }],
) }} />,
};
@@ -0,0 +1,194 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
import { BookOpen, KeyRound } from "lucide-react";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { buildSlackSetupPrompt, SlackSetupPrompt } from "@/pages/apps/chat/SlackSetupPrompt";
import { GitHubSetupPrompt } from "@/pages/apps/chat/GitHubSetupPrompt";
import { McpConfigHelpDialog } from "@/pages/tools/McpConfigHelpDialog";
import { AgentInstructions } from "@/components/routine-triggers/WebhookFields";
import { IssueContinuationHandoff } from "@/components/IssueContinuationHandoff";
import { webhookAgentInstructions } from "../fixtures/routineTriggerWizard";
import { storybookContinuationHandoff } from "../fixtures/paperclipData";
const slackPrompt = buildSlackSetupPrompt(import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? "");
const apiPrompt = `Help me connect my agent to the Paperclip API.
First, ask me for my Paperclip instance URL if it is not already available. Read its API documentation and identify the company and agent I want to use.
Guide me through creating a dedicated agent API key. Store it securely in my agent's environment; do not paste it into code, logs, or chat.
Configure the base URL and authentication, then verify the connection with a read-only request. Show me what worked and ask before making any changes to my company.`;
const meta = {
title: "Components/Agent setup prompt",
component: AgentSetupPrompt,
parameters: {
layout: "fullscreen",
docs: { description: { component: "A reusable handoff to an agent. Hover or focus to fan out the logos, click once to copy the prompt and open its preview. Logos travel into the preview; clipboard success stays in place. Supports reduced motion, keyboard dismissal, and manual copying when clipboard access fails. The default story copies the complete production Slack setup prompt." } },
},
args: {
prompt: slackPrompt,
title: "Agent setup",
description: "Paste this into your agent to configure your Slack bot.",
label: "Set up with an agent",
side: "top",
align: "start",
variant: "outline",
},
argTypes: {
variant: { control: "select", options: ["outline", "ghost"] },
side: { control: "select", options: ["top", "bottom", "left", "right"] },
align: { control: "select", options: ["start", "center", "end"] },
},
render: (args) => <div className="flex min-h-screen items-center justify-center p-6"><AgentSetupPrompt {...args} /></div>,
} satisfies Meta<typeof AgentSetupPrompt>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
parameters: { layout: "fullscreen" },
render: (args) => (
<div className="mx-auto flex min-h-screen w-full max-w-xl flex-col justify-center gap-8 p-6">
<div className="flex flex-col gap-3">
<span className="text-xs font-medium uppercase tracking-widest text-muted-foreground">A little help with setup</span>
<h1 className="text-3xl font-semibold tracking-tight">Let your agent handle it.</h1>
<p className="max-w-md text-sm leading-relaxed text-muted-foreground">Give your agent the instructions. Get back to what you were building.</p>
</div>
<div className="flex flex-col gap-6 rounded-xl border border-border bg-card p-6">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<img src="/brands/apps/slack.svg" alt="" className="size-6" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">Connect Slack</h2>
<p className="text-sm leading-relaxed text-muted-foreground">Bring your Paperclip agent into your workspace.</p>
</div>
</div>
<div><AgentSetupPrompt {...args} /></div>
</div>
<p className="text-xs text-muted-foreground">Hover the logos. Click once to copy and preview.</p>
</div>
),
};
export const ButtonOnly: Story = {};
export const OpenPreview: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(within(canvasElement).getByRole("button", { name: "Set up with an agent" }));
await expect(within(canvasElement.ownerDocument.body).getByRole("dialog", { name: "Agent setup" })).toBeVisible();
},
};
export const Light: Story = { ...Playground, globals: { theme: "light" } };
export const Mobile: Story = {
...Playground,
globals: { viewport: { value: "mobile1", isRotated: false } },
};
export const ApiSetup: Story = {
args: { prompt: apiPrompt, description: "Paste this into your agent to configure the API.", label: "Configure with an agent" },
};
export const ClipboardCheck: Story = {
render: (args) => (
<div className="mx-auto flex min-h-screen w-full max-w-lg flex-col justify-center gap-4 p-6">
<div><AgentSetupPrompt {...args} /></div>
<label htmlFor="clipboard-check" className="text-sm text-muted-foreground">Click the setup button, then paste here to check the full text.</label>
<Textarea id="clipboard-check" placeholder="Paste the copied prompt here" rows={8} className="font-mono text-xs" />
</div>
),
};
export const QuickAccess: Story = {
args: { prompt: apiPrompt, description: "Paste this into your agent to configure the API.", variant: "ghost", label: "Agent setup" },
render: (args) => (
<div className="flex min-h-screen items-center p-6">
<div className="flex w-64 flex-col gap-1 rounded-xl border border-border bg-card p-2">
<p className="px-3 py-2 text-xs text-muted-foreground">Quick access</p>
<Button variant="ghost" className="justify-start" disabled><KeyRound />API key</Button>
<AgentSetupPrompt {...args} />
<Button variant="ghost" className="justify-start" disabled><BookOpen />Documentation</Button>
</div>
</div>
),
};
export const ClipboardUnavailable: Story = {
beforeEach: () => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const execCommand = Object.getOwnPropertyDescriptor(document, "execCommand");
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async () => { throw new Error("Clipboard disabled for this story"); } } });
Object.defineProperty(document, "execCommand", { configurable: true, value: () => false });
return () => {
if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
else Reflect.deleteProperty(navigator, "clipboard");
if (execCommand) Object.defineProperty(document, "execCommand", execCommand);
else Reflect.deleteProperty(document, "execCommand");
};
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(within(canvasElement).getByRole("button", { name: "Set up with an agent" }));
await expect(body.getByRole("alert")).toHaveTextContent("Could not copy automatically");
await expect(body.getByRole("textbox", { name: "Setup prompt" })).toHaveValue(slackPrompt);
},
};
export const AppPlacements: Story = {
render: () => (
<div className="mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-8 p-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Hand it to your agent</h1>
<p className="text-sm text-muted-foreground">The same one-click handoff across the app. These are the production controls, with sample data for webhooks and task continuation.</p>
</div>
<section className="space-y-3">
<h2 className="text-sm font-medium">Slack connection</h2>
<SlackSetupPrompt instanceUrl={import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? ""} />
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium">GitHub connection</h2>
<GitHubSetupPrompt instanceUrl={import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? ""} />
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium">MCP configuration help</h2>
<McpConfigHelpDialog />
</div>
</section>
<AgentInstructions value={webhookAgentInstructions("github")} />
<IssueContinuationHandoff document={storybookContinuationHandoff} />
</div>
),
};
export const InsideMcpHelp: Story = {
render: () => <div className="p-6"><McpConfigHelpDialog /></div>,
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
// Synthetic play events lack clipboard user activation in some browsers.
// Restore the real clipboard after the assertions for manual exploration.
const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const writeText = fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
await userEvent.click(within(canvasElement).getByRole("button", { name: "Get help creating an MCP config" }));
const trigger = await body.findByRole("button", { name: "Get a config with an agent" });
await userEvent.click(trigger);
await waitFor(async () => {
await expect(body.getByRole("dialog", { name: "MCP configuration" })).toBeVisible();
await expect(body.getByRole("button", { name: "Copied to clipboard" })).toHaveFocus();
await expect(writeText).toHaveBeenCalledTimes(1);
await expect(writeText).toHaveBeenCalledWith(MCP_CONFIG_HELP_PROMPT);
});
await userEvent.keyboard("{Escape}");
await waitFor(async () => {
await expect(body.queryByRole("dialog", { name: "MCP configuration" })).not.toBeInTheDocument();
await expect(body.getByRole("dialog", { name: "Ask an agent for an MCP config" })).toBeVisible();
await expect(trigger).toHaveFocus();
await expect(writeText).toHaveBeenCalledTimes(1);
});
} finally {
if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
else Reflect.deleteProperty(navigator, "clipboard");
}
},
};
@@ -10,7 +10,7 @@ const meta = {
component: SlackSetupPrompt,
parameters: {
layout: "padded",
docs: { description: { component: "Production copy button and full browser-agent prompt. The surrounding agent selection is a static placement preview; it does not create a connection. Configure PAPERCLIP_STORYBOOK_API_URL to include a real instance origin in copied instructions. Otherwise the prompt asks for the instance URL." } },
docs: { description: { component: "Production animated setup button, prompt preview, and clipboard feedback. The surrounding agent selection is a static placement preview; it does not create a connection. Configure PAPERCLIP_STORYBOOK_API_URL to include a real instance origin in copied instructions. Otherwise the prompt asks for the instance URL." } },
},
args: { instanceUrl },
} satisfies Meta<typeof SlackSetupPrompt>;
-413
View File
@@ -1,413 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ToolCatalogEntry,
ToolConnectionAccessSummary,
ToolConnectionTestAgent,
ToolConnectionTestCallResult,
ToolConnectionTestDecision,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
const CONNECTION = "conn-sheets";
type StoryAgent = ToolConnectionTestAgent & {
effectiveAccess: ToolConnectionAccessSummary;
};
// ---------------------------------------------------------------------------
// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
// ---------------------------------------------------------------------------
function tool(
id: string,
toolName: string,
title: string,
description: string,
read: boolean,
inputSchema: Record<string, unknown> | null = null,
): ToolCatalogEntry {
return {
id,
companyId: "company-storybook",
applicationId: "app-sheets",
connectionId: CONNECTION,
entryKind: "tool",
name: toolName,
toolName,
title,
description,
inputSchema,
outputSchema: null,
annotations: null,
riskLevel: read ? "read" : "write",
isReadOnly: read,
isWrite: !read,
isDestructive: false,
status: "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
versionHash: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolCatalogEntry;
}
const READ_SHEET_SCHEMA = {
type: "object",
properties: {
spreadsheetId: { type: "string", title: "Spreadsheet" },
sheet: { type: "string", title: "Sheet" },
range: { type: "string", title: "Range", description: "e.g. A1:F50" },
},
required: ["spreadsheetId", "sheet"],
};
const APPEND_ROW_SCHEMA = {
type: "object",
properties: {
spreadsheetId: { type: "string", title: "Spreadsheet" },
sheet: { type: "string", title: "Sheet" },
values: { type: "string", title: "Values", description: "Comma-separated cell values" },
},
required: ["spreadsheetId", "sheet", "values"],
};
const DELETE_ROW_SCHEMA = {
type: "object",
properties: {
spreadsheetId: { type: "string", title: "Spreadsheet" },
rowNumber: { type: "number", title: "Row number" },
},
required: ["spreadsheetId", "rowNumber"],
};
const CATALOG: ToolCatalogEntry[] = [
tool("r1", "list_spreadsheets", "List spreadsheets", "See the spreadsheets in this account.", true),
tool("r2", "read_sheet", "Read a sheet", "Get rows and cell values from a sheet.", true, READ_SHEET_SCHEMA),
tool("r3", "search_rows", "Search rows", "Find rows that match a query.", true),
tool("r4", "get_cell", "Get a cell", "Read a single cell value.", true),
tool("r5", "list_sheets", "List sheets", "See the tabs in a spreadsheet.", true),
tool("r6", "get_metadata", "Get spreadsheet info", "Read a spreadsheet's title and tabs.", true),
tool("r7", "describe_columns", "Describe columns", "List the column headers in a sheet.", true),
tool("w1", "append_row", "Append a row", "Add a new row to the bottom of a sheet.", false, APPEND_ROW_SCHEMA),
tool("w2", "update_cell", "Update a cell", "Change a single cell value.", false),
tool("w3", "create_sheet", "Add a sheet", "Create a new tab in a spreadsheet.", false),
tool("w4", "clear_range", "Clear a range", "Empty the cells in a range.", false),
tool("w5", "delete_row", "Delete a row", "Remove a row from a sheet permanently.", false, DELETE_ROW_SCHEMA),
];
function decisionTool(entry: ToolCatalogEntry, decision: ToolConnectionTestDecision) {
return {
toolName: entry.toolName,
gatewayToolName: `sheets__${entry.toolName}`,
displayName: entry.title,
risk: (entry.isReadOnly ? "read" : "write") as "read" | "write" | "destructive",
decision,
reasonCode: null,
matchedPolicyIds: [],
};
}
// Decisions: 9 allowed (7 read + 2 write), 2 ask first, 1 off.
const DECISIONS: Record<string, ToolConnectionTestDecision> = {
list_spreadsheets: "allowed",
read_sheet: "allowed",
search_rows: "allowed",
get_cell: "allowed",
list_sheets: "allowed",
get_metadata: "allowed",
describe_columns: "allowed",
append_row: "ask_first",
update_cell: "allowed",
create_sheet: "allowed",
clear_range: "ask_first",
delete_row: "off",
};
function buildAgent(
id: string,
name: string,
decisions: Record<string, ToolConnectionTestDecision>,
orgDepth = 1,
): StoryAgent {
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
return {
id,
name,
role: "engineer",
title: "Engineer",
status: "active",
orgDepth,
effectiveAccess: {
connectionId: CONNECTION,
toolCount: tools.length,
allowedCount: tools.filter((t) => t.decision === "allowed").length,
askFirstCount: tools.filter((t) => t.decision === "ask_first").length,
offCount: tools.filter((t) => t.decision === "off").length,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools,
},
};
}
const AGENTS: StoryAgent[] = [
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
buildAgent("agent-codex", "CodexCoder", {
...DECISIONS,
create_sheet: "ask_first",
clear_range: "off",
}),
];
// ---------------------------------------------------------------------------
// DOM driver — expand a row, fill its fields, press Run.
// ---------------------------------------------------------------------------
type Step =
| { kind: "expand"; title: string }
| { kind: "fill"; values: string[] }
| { kind: "click"; label: string };
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function runScript(steps: Step[]) {
let index = 0;
const tick = (attempt: number) => {
if (index >= steps.length) return;
const step = steps[index];
if (step.kind === "expand") {
const btn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes(step.title),
);
if (btn) {
btn.click();
index += 1;
window.setTimeout(() => tick(0), 60);
return;
}
} else if (step.kind === "fill") {
const inputs = Array.from(document.querySelectorAll("input")).filter(
(i) => i.getAttribute("aria-label") !== "Find an action" && i.getAttribute("aria-label") !== "Search agents",
);
if (inputs.length >= step.values.length) {
step.values.forEach((value, i) => setInputValue(inputs[i] as HTMLInputElement, value));
index += 1;
window.setTimeout(() => tick(0), 60);
return;
}
} else if (step.kind === "click") {
const btn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === step.label,
);
if (btn) {
btn.click();
index += 1;
window.setTimeout(() => tick(0), 60);
return;
}
}
if (attempt < 40) window.setTimeout(() => tick(attempt + 1), 50);
};
tick(0);
}
function seededClient(agents: StoryAgent[]): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
agents: agents.map(({ effectiveAccess: _effectiveAccess, ...agent }) => agent),
});
for (const agent of agents) {
client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, agent.id), {
access: agent.effectiveAccess,
});
}
return client;
}
function TestHost({
script,
runResult,
runDelayMs = 120,
agents = AGENTS,
quarantined = [],
}: {
script?: Step[];
runResult?: ToolConnectionTestCallResult;
runDelayMs?: number;
agents?: StoryAgent[];
quarantined?: ToolCatalogEntry[];
}) {
const client = useMemo(() => seededClient(agents), [agents]);
const fetchPatched = useRef(false);
// Stub the test-call POST so the run states render without a backend.
useEffect(() => {
if (fetchPatched.current) return;
fetchPatched.current = true;
const original = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.includes("/test-calls")) {
await new Promise((resolve) => window.setTimeout(resolve, runDelayMs));
return Response.json(runResult ?? { decision: "allowed", invocationId: "inv-storybook", result: {} });
}
return original(input, init);
};
return () => {
window.fetch = original;
};
}, [runResult, runDelayMs]);
useEffect(() => {
if (!script) return;
const timer = window.setTimeout(() => runScript(script), 150);
return () => window.clearTimeout(timer);
}, [script]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-3xl p-6">
<TestPanel connectionId={CONNECTION} appName="Google Sheets" active={CATALOG} quarantined={quarantined} />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Apps/Test tab (PAP-11350)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
name: "Default — grouped actions + access badges",
render: () => <TestHost />,
};
export const AllowedResult: Story = {
name: "Allowed — result panel",
render: () => (
<TestHost
runResult={{
decision: "allowed",
invocationId: "inv-allowed",
result: [
{ Name: "Acme Co.", Stage: "Demo", Owner: "Dotta", Updated: "Tue" },
{ Name: "Globex", Stage: "Trial", Owner: "Dotta", Updated: "Mon" },
{ Name: "Initech", Stage: "Closed", Owner: "CodexCoder", Updated: "Sun" },
{ Name: "Soylent", Stage: "Discovery", Owner: "QA", Updated: "Fri" },
{ Name: "Umbrella", Stage: "Trial", Owner: "Dotta", Updated: "Thu" },
],
}}
script={[
{ kind: "expand", title: "Read a sheet" },
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const AskFirstResult: Story = {
name: "Ask first — sent for approval",
render: () => (
<TestHost
runResult={{ decision: "ask_first", invocationId: "inv-ask", actionRequestId: "req-1" }}
script={[
{ kind: "expand", title: "Append a row" },
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads", "Wayne Industries, Demo, Dotta"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const ErrorResult: Story = {
name: "Error — what the app said + what to try",
render: () => (
<TestHost
runResult={{
decision: "allowed",
invocationId: "inv-error",
error: { message: "Requested entity was not found.", reasonCode: "NOT_FOUND" },
}}
script={[
{ kind: "expand", title: "Read a sheet" },
{ kind: "fill", values: ["1AbCxyz…NotFound", "Leads"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const RunningState: Story = {
name: "Running — in-flight card",
render: () => (
<TestHost
runDelayMs={20000}
script={[
{ kind: "expand", title: "Read a sheet" },
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const OffAction: Story = {
name: "Off — explanation + open Permissions",
render: () => <TestHost script={[{ kind: "expand", title: "Delete a row" }]} />,
};
// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
const AGENTS_WITH_AUDIT: StoryAgent[] = AGENTS.map((agent, i) =>
i === 0
? {
...agent,
effectiveAccess: {
...agent.effectiveAccess,
lastChangedAt: new Date("2026-06-17T09:00:00Z").toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
}
: agent,
);
export const OffAuditHint: Story = {
name: "Off — last-changed audit hint (PAP-11404)",
render: () => (
<TestHost agents={AGENTS_WITH_AUDIT} script={[{ kind: "expand", title: "Delete a row" }]} />
),
};
const QUARANTINED: ToolCatalogEntry[] = [
{ ...tool("q1", "rename_sheet", "Rename a sheet", "Change a sheet's name.", false), status: "quarantined" } as ToolCatalogEntry,
];
export const QuarantinedAction: Story = {
name: "New — quarantined action not yet on (PAP-11404)",
render: () => (
<TestHost quarantined={QUARANTINED} script={[{ kind: "expand", title: "Rename a sheet" }]} />
),
};