diff --git a/Dockerfile b/Dockerfile index eddc097b0ae..1db10902f67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 5d37d441c8b..2615e61611e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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 `. -- 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. diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 897a8f3cd19..7d9bb1ea954 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -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 diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index ec7ea067242..a1d7e0e14f7 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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. diff --git a/doc/connections/ASANA.md b/doc/connections/ASANA.md new file mode 100644 index 00000000000..117b7d0394b --- /dev/null +++ b/doc/connections/ASANA.md @@ -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. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 58bf3c5e9eb..facfb020010 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -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. diff --git a/doc/connections/README.md b/doc/connections/README.md index ac7196cd2bf..7e1bfe8f051 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -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). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 162bc2b09cc..2ef0bc115fc 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -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", diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 78ca705b7e6..a663f1f0e80 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -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). | diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index adce8e88f1f..da32aedf637 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -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", diff --git a/packages/shared/src/app-definitions/asana.json b/packages/shared/src/app-definitions/asana.json index ecda6f4b603..4cc2859043a 100644 --- a/packages/shared/src/app-definitions/asana.json +++ b/packages/shared/src/app-definitions/asana.json @@ -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 } ] } diff --git a/packages/shared/src/asana-connectors.ts b/packages/shared/src/asana-connectors.ts new file mode 100644 index 00000000000..6a585a7cdd6 --- /dev/null +++ b/packages/shared/src/asana-connectors.ts @@ -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; +} diff --git a/packages/shared/src/connection-setup-state.test.ts b/packages/shared/src/connection-setup-state.test.ts index e4cc83af584..252f1a1627b 100644 --- a/packages/shared/src/connection-setup-state.test.ts +++ b/packages/shared/src/connection-setup-state.test.ts @@ -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"); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 07d194f1eb3..8aeb0f2fcf9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index b7b532a0536..de6cc3d98fe 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -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}; 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}; 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> } export type SelfServeMcpAuthMode = diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index dfc05e91da2..5abb3a64f66 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -182,6 +182,8 @@ export interface ToolConnection { transportConfig: Record; config?: Record; 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. */ diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index 9be04386dad..d55b839e1f8 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -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 field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); +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",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); 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();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a03d2d565a..3d48251ab01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs index bff638cdc44..dcedc7f075a 100644 --- a/scripts/__tests__/provision-worktree-self-heal.test.mjs +++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs @@ -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 }); diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 7902a48008e..1ecfeb2e19e 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -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.`, diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 2418fa3bb06..bb57604c832 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -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() { diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 8d2b726bba5..3e52e6e4652 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -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); + }); }); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 7fc5d900106..6aee6ee0ecf 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -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, + 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, + 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(); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index fd0caefa8e1..187135d1959 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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 }); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index f4c51194977..ecf346118b7 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -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; } diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 576fc05f5b7..307c9262b51 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -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({ diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 677d64fb1d6..fb52bed38ca 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -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 { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 004a2f98e81..f1f894a8bbb 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -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 { - 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; }, diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index f087e1ad77e..185d78548ac 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -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"); diff --git a/ui/package.json b/ui/package.json index 64b7a759e9f..15533b4a89c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/AgentSetupPrompt.tsx b/ui/src/components/AgentSetupPrompt.tsx new file mode 100644 index 00000000000..df025d7eb3a --- /dev/null +++ b/ui/src/components/AgentSetupPrompt.tsx @@ -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; hidden: boolean }) { + return ( + + ); +} + +function readLogoPositions(group: HTMLSpanElement | null) { + return Array.from(group?.querySelectorAll(".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; toPreview: boolean } | null>(null); + const triggerLogos = useRef(null); + const previewLogos = useRef(null); + const flyingLogos = useRef(null); + const activationOrigins = useRef | null>(null); + const copyAttempt = useRef(0); + const hasUserCopyAttempt = useRef(false); + const copying = useRef(false); + const copyButton = useRef(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(".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 ( + <> + + + + + { + 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(); + }} + > +
+
+

{title}

+

{description}

+
+ +
+ +
+
+ {status === "failed" ? ( +