From 8c3b8c432a264d1582bbf06c68a6f54fc465b634 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:05:53 -0500 Subject: [PATCH] Simplify app connections and enable managed Google access (#12728) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps subsystem gives humans and agents governed access to external tools. > - The current connection flow hides Apps behind an experimental gate and repeats setup text. > - Google sharing choices and generic MCP permissions do not use one consistent opening model. > - Self-hosted installs also need a safe default origin for managed OAuth without a manual config file. > - This pull request makes Apps available, simplifies connection setup, and applies one governed permissions model. > - The benefit is a shorter connection flow that works on a clean self-hosted install. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the Apps connection setup flow, managed Google connection flow, generic MCP connection flow, navigation, and runtime origin discovery. **Subsystem affected** Cross-cutting. This changes `ui/`, `server/`, `packages/shared/`, connector documentation, and browser tests. **Current behavior** Apps require an experimental switch. Setup pages repeat titles and explanatory copy. Connection names require manual input. Google credential sharing does not always offer both personal and organization access. Generic MCP providers do not start with the same permission choices. Managed OAuth needs a public URL setting even when the request already has a safe HTTPS origin. **Proposed behavior** Apps are available by default. Setup asks only for required permissions and sharing choices. Paperclip creates conflict-free connection names. Google apps and generic MCP providers use the same human and agent access model. Managed OAuth derives a validated same-origin HTTPS URL when no explicit public URL is set. **Reason and benefit** A clean self-hosted install can connect a managed Google app without hidden setup. Humans can share a service account with their organization. The shorter flow reduces duplicated choices and setup errors. **Breaking changes** The Apps experimental switch is removed. Existing connection APIs remain compatible. New connections can receive a numeric suffix when a name already exists. No duplicate or related public issue was found. ## What Changed - Removed the Apps experimental gate and the breadcrumb that leaves the Apps section. - Simplified all connection setup pages and moved optional provider requirements into one small link. - Added consistent human and agent access choices for Google apps, Zapier, and generic MCP connections. - Added organization sharing to Google Workspace credentials while keeping personal access available. - Generated connection names automatically and resolved name conflicts with numeric suffixes. - Derived a validated public HTTPS origin from the request for config-free managed OAuth. - Updated connector contracts, tests, browser coverage, and authoring documentation. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` - `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts server/src/__tests__/generic-mcp-connection.test.ts` (273 passed) - Targeted UI/service regression suite (308 passed) - Six targeted Playwright connection journeys on a fresh onboarding instance (6 passed) - Fresh-install browser proof through Tailscale HTTPS: enrolled with Paperclip Cloud, connected managed Google Drive, and completed a real read operation. - [Exact-head CI run](https://github.com/paperclipai/paperclip/actions/runs/33669760711): all 23 matrix jobs passed, including build, typecheck, server, serialized, canary, and all browser shards. - Greptile 5/5 on `0ae2a859f269984ee950d0af231a5b09a06f3dfd`, with no unresolved review threads. ## Risks Apps are now visible to all operators. The removed experimental flag no longer hides unfinished app definitions. Managed Google availability still depends on the Cloud profile rollout and active instance enrollment. Automatic conflict handling changes only the display name of a newly conflicting connection. > I checked [`ROADMAP.md`](ROADMAP.md). MCP Tool Gateway and Apps are shipped. Connected Apps is planned, and this change improves the existing shipped connection flow. ## Model Used OpenAI Codex, GPT-5, with reasoning, browser control, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/CONNECTOR-PLAYBOOK.md | 13 +- doc/connections/GMAIL.md | 15 +- doc/connections/GOOGLE-WORKSPACE.md | 14 +- packages/shared/src/app-definitions.test.ts | 4 +- .../shared/src/app-definitions/gmail.json | 12 +- .../src/app-definitions/google-calendar.json | 12 +- .../src/app-definitions/google-chat.json | 12 +- .../src/app-definitions/google-docs.json | 12 +- .../src/app-definitions/google-drive.json | 12 +- .../src/app-definitions/google-people.json | 6 +- .../src/app-definitions/google-sheets.json | 12 +- .../src/app-definitions/google-slides.json | 12 +- .../google-workspace-search.json | 6 +- packages/shared/src/feature-catalog.ts | 8 +- packages/shared/src/types/instance.ts | 1 + packages/shared/src/types/tool-access.ts | 2 + .../shared/src/validators/instance.test.ts | 4 +- packages/shared/src/validators/instance.ts | 5 +- .../__tests__/generic-mcp-connection.test.ts | 20 +- .../instance-settings-managed-overlay.test.ts | 31 +- .../instance-settings-service.test.ts | 11 +- .../src/__tests__/tool-access-service.test.ts | 267 +++++++++++- server/src/middleware/board-mutation-guard.ts | 16 +- .../tool-access-connection-intent.test.ts | 18 + server/src/routes/tool-access.ts | 125 +++++- server/src/services/instance-settings.ts | 9 +- .../paperclip-cloud-connector-enrollment.ts | 4 + server/src/services/tool-access.ts | 177 +++++++- tests/e2e/app-not-connected.spec.ts | 3 +- .../e2e/application-delete-screenshot.spec.ts | 3 - tests/e2e/applications-crud.spec.ts | 2 - tests/e2e/apps-dark-mode-shots.spec.ts | 2 - tests/e2e/apps-prosumer-mcp-flow.spec.ts | 7 +- tests/e2e/connection-intents.spec.ts | 6 +- tests/e2e/mcp-user-stories.spec.ts | 1 - tests/e2e/smoke-lab.shared.ts | 2 +- ui/src/App.tsx | 61 ++- ui/src/api/tools.ts | 4 +- .../components/AppsExperimentalGate.test.tsx | 84 ---- ui/src/components/AppsExperimentalGate.tsx | 10 - ui/src/components/Layout.test.tsx | 6 +- ui/src/components/Layout.tsx | 8 +- ui/src/components/Sidebar.test.tsx | 24 +- ui/src/components/Sidebar.tsx | 3 +- .../connections/ConnectionSetupFlow.tsx | 408 ++++++++---------- ui/src/hooks/useAppsEnabled.ts | 15 - .../InstanceExperimentalSettings.test.tsx | 37 +- ui/src/pages/InstanceExperimentalSettings.tsx | 12 - ui/src/pages/apps/AppDetail.tsx | 7 +- ui/src/pages/apps/AppNotConnected.tsx | 7 +- ui/src/pages/apps/AppsConnect.test.tsx | 229 +++++++--- ui/src/pages/apps/AppsReview.tsx | 7 +- ui/src/pages/apps/Browse.test.tsx | 7 +- ui/src/pages/apps/Browse.tsx | 12 +- ui/src/pages/apps/Connections.tsx | 5 +- ui/src/pages/apps/gateways/GatewayDetail.tsx | 7 +- ui/src/pages/apps/gateways/GatewaysList.tsx | 7 +- ui/src/pages/apps/generic-mcp-connect.test.ts | 5 +- ui/src/pages/apps/generic-mcp-connect.ts | 8 +- .../permitted-vs-installed.stories.tsx | 12 +- 60 files changed, 1183 insertions(+), 668 deletions(-) delete mode 100644 ui/src/components/AppsExperimentalGate.test.tsx delete mode 100644 ui/src/components/AppsExperimentalGate.tsx delete mode 100644 ui/src/hooks/useAppsEnabled.ts diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index bd4e49f78c..9cbbb88db3 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -746,9 +746,12 @@ Walk the user path: For OAuth, the instance callback must be browser-reachable and must match the provider registration. Loopback HTTP is acceptable only when provider and -Paperclip redirect policies permit it. A worktree exposed through HTTPS needs a -unique, correct `PAPERCLIP_PUBLIC_URL`; internal service hostnames are not valid -browser callback origins. +Paperclip redirect policies permit it. Browser-started setup on an authenticated +private instance automatically uses the same-origin HTTPS address that served +the setup page, including a Tailscale Serve address; the request must pass the +hostname and board-mutation guards. An explicit `PAPERCLIP_PUBLIC_URL` remains +available for non-browser starts and unusual proxy topologies. Internal service +hostnames are not valid browser callback origins. Use the browser signed-in session only for an explicitly authorized live proof. Do not inspect cookies, storage, saved passwords, or unrelated account data. @@ -1782,8 +1785,8 @@ plain-HTTP non-loopback origins. loopback HTTP (Notion's redirect-URI rule). A plain-HTTP non-loopback origin gets "This provider requires an HTTPS or loopback origin. Configure TLS before connecting." — add TLS first (e.g. a tailscale cert, as - paperclip-dev did). The `enableApps` experimental setting must be on for - `/apps/*` routes. The connecting user must be allowed to install + paperclip-dev did). Apps is a standard product surface and `/apps/*` routes + are always available. The connecting user must be allowed to install integrations in their Notion workspace. - How to verify: visit `/PAP/apps/connect?source=notion`, complete the Notion consent flow, and land on the wizard's actions step listing `notion-*` diff --git a/doc/connections/GMAIL.md b/doc/connections/GMAIL.md index 60332d488a..1cdbf01536 100644 --- a/doc/connections/GMAIL.md +++ b/doc/connections/GMAIL.md @@ -217,7 +217,7 @@ sequenceDiagram U->>P: Return to exact enrolled instance URL P->>C: Signed one-time claim C-->>P: Instance-encrypted token response - P->>V: Encrypt tokens and bind them to the user's grant + P->>V: Encrypt tokens and bind them to the chosen user or organization grant ``` Before an instance can create a session: @@ -232,8 +232,11 @@ Before an instance can create a session: initiating administrator must complete the return callback. 3. Paperclip Cloud binds the account, opaque instance id, both public keys, deployment environment, and exact allowed browser return origins. -4. Tailscale HTTPS origins are allowed only when explicitly enrolled. Loopback - HTTP is development-only. Other plaintext origins are rejected. +4. On authenticated private instances, the setup request supplies its verified + same-origin HTTPS address and enrollment binds it automatically. This makes a + Tailscale HTTPS setup config-free while still rejecting a bare or mismatched + `Host` header. Loopback HTTP is development-only; other plaintext origins are + rejected. 5. Create, claim, refresh, and supported revoke requests are signed, audience-bound, timestamped, and protected by a one-time `jti` replay cache. @@ -296,9 +299,11 @@ keys before they deploy a binary that enables the Cloud connector. ## Paperclip access defaults -The first Gmail release is personal-only: +Gmail uses the same credential ownership choice as the rest of the Apps setup: -- **Just me** is the only credential ownership choice. +- **Just me** stores the Gmail credential on the connecting user's grant. +- **Any human in the company** stores it on the default organization grant so a + deliberately shared mailbox or Workspace account can back company-wide use. - The disclosure states that Gmail access can search/read mail and create drafts. Sending mail is not enabled. - A user grant does not automatically authorize an agent. The user must also diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index 5067c7c8ee..511f3f2741 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -73,9 +73,12 @@ methods available for that capability: - **Use the Paperclip robot account** remains an additional Google Sheets-only option for explicitly shared spreadsheets. -OAuth grants begin as personal connections. Existing promotion controls may -later make an eligible connection available to the company without silently -changing the underlying Google principal. +Before Google consent, the setup flow asks whether the credential is for just +the connecting user or for any human in the company. A personal choice stores +the tokens only on that user's grant. A company choice stores them on the +default organization grant, while still recording which signed-in Google +principal completed consent so refresh and reconnect stay bound to that +principal. ## Broker profiles @@ -121,7 +124,10 @@ path. Cloud-hosted stacks receive these values through the existing per-stack secret delivery path. A self-hosted instance creates its keys during enrollment and stores them with owner-only permissions in the instance's ignored secret -directory. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible +directory. The setup page supplies its authenticated same-origin HTTPS address +to enrollment, so a normal Tailscale-hosted self-hoster does not need to edit +`config.json` or set `PAPERCLIP_PUBLIC_URL`; the enrolled origin becomes the +durable callback binding. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible Paperclip ID protocol and are not read aliases. Enroll with Paperclip Cloud and reconnect legacy grants before their old access tokens expire. diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 655c793851..e22d2e8f8d 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -203,7 +203,7 @@ describe("AppDefinition catalog",()=>{ oauthStrategy:"paperclip_cloud_connector", connectorProfile:expected.profile, capabilityProfile:{key:expected.capability}, - grantKinds:["user"], + grantKinds:["user","organization"], ownershipModes:["platform_shared"], defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes}, riskTier:expected.riskTier, @@ -215,7 +215,7 @@ describe("AppDefinition catalog",()=>{ &&method.capabilityProfile?.key===expected.capability ); expect(customer,`${expected.profile}:customer-fallback`).toMatchObject({ - grantKinds:["user"], + grantKinds:["user","organization"], ownershipModes:["customer"], defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes}, riskTier:expected.riskTier, diff --git a/packages/shared/src/app-definitions/gmail.json b/packages/shared/src/app-definitions/gmail.json index d04a6aeb16..a5d4b12cde 100644 --- a/packages/shared/src/app-definitions/gmail.json +++ b/packages/shared/src/app-definitions/gmail.json @@ -41,7 +41,8 @@ "description": "Search and read messages, threads, drafts, and labels." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -70,7 +71,8 @@ "description": "Search and read messages, threads, drafts, and labels." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -112,7 +114,8 @@ "description": "Read Gmail and create drafts for review in Gmail." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -143,7 +146,8 @@ "description": "Read Gmail and create drafts for review in Gmail." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-calendar.json b/packages/shared/src/app-definitions/google-calendar.json index ef74bb6ca8..b8b00444c1 100644 --- a/packages/shared/src/app-definitions/google-calendar.json +++ b/packages/shared/src/app-definitions/google-calendar.json @@ -41,7 +41,8 @@ "description": "Read calendars, events, and availability." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Read calendars, events, and availability." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -116,7 +118,8 @@ "description": "Create, update, respond to, and delete events." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -148,7 +151,8 @@ "description": "Create, update, respond to, and delete events." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-chat.json b/packages/shared/src/app-definitions/google-chat.json index 4f6b4fc2b5..3f075f9cde 100644 --- a/packages/shared/src/app-definitions/google-chat.json +++ b/packages/shared/src/app-definitions/google-chat.json @@ -42,7 +42,8 @@ "description": "Search conversations and read messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -74,7 +75,8 @@ "description": "Search conversations and read messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -120,7 +122,8 @@ "description": "Read Chat and send messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -154,7 +157,8 @@ "description": "Read Chat and send messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-docs.json b/packages/shared/src/app-definitions/google-docs.json index 035038e71e..bb6eb24a18 100644 --- a/packages/shared/src/app-definitions/google-docs.json +++ b/packages/shared/src/app-definitions/google-docs.json @@ -42,7 +42,8 @@ "description": "Read document text and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Read document text and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -115,7 +117,8 @@ "description": "Read and update documents." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -147,7 +150,8 @@ "description": "Read and update documents." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-drive.json b/packages/shared/src/app-definitions/google-drive.json index d47ebc7b12..6a944df553 100644 --- a/packages/shared/src/app-definitions/google-drive.json +++ b/packages/shared/src/app-definitions/google-drive.json @@ -42,7 +42,8 @@ "description": "Search and read files and metadata." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -71,7 +72,8 @@ "description": "Search and read files and metadata." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -113,7 +115,8 @@ "description": "Read files and create or copy files." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -144,7 +147,8 @@ "description": "Read files and create or copy files." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-people.json b/packages/shared/src/app-definitions/google-people.json index ffaa3384a9..07121347fb 100644 --- a/packages/shared/src/app-definitions/google-people.json +++ b/packages/shared/src/app-definitions/google-people.json @@ -41,7 +41,8 @@ "description": "Search contacts, directory people, and your profile." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Search contacts, directory people, and your profile." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-sheets.json b/packages/shared/src/app-definitions/google-sheets.json index fa63fe17a8..83e0ef0d31 100644 --- a/packages/shared/src/app-definitions/google-sheets.json +++ b/packages/shared/src/app-definitions/google-sheets.json @@ -43,7 +43,8 @@ "description": "Read spreadsheet values and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -73,7 +74,8 @@ "description": "Read spreadsheet values and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -116,7 +118,8 @@ "description": "Read and update spreadsheet values, formulas, and dimensions." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -148,7 +151,8 @@ "description": "Read and update spreadsheet values, formulas, and dimensions." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-slides.json b/packages/shared/src/app-definitions/google-slides.json index 1430e0e32e..ccde0900e2 100644 --- a/packages/shared/src/app-definitions/google-slides.json +++ b/packages/shared/src/app-definitions/google-slides.json @@ -42,7 +42,8 @@ "description": "Read presentation slides and content." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Read presentation slides and content." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -115,7 +117,8 @@ "description": "Read and update presentations." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -147,7 +150,8 @@ "description": "Read and update presentations." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-workspace-search.json b/packages/shared/src/app-definitions/google-workspace-search.json index cfe2f1542d..f3ec9d00ed 100644 --- a/packages/shared/src/app-definitions/google-workspace-search.json +++ b/packages/shared/src/app-definitions/google-workspace-search.json @@ -41,7 +41,8 @@ "description": "Search Gmail, Drive, Calendar, and Chat without write access." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -74,7 +75,8 @@ "description": "Search Gmail, Drive, Calendar, and Chat without write access." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 68c0903afd..48b516eda9 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -82,12 +82,12 @@ export const INSTANCE_FEATURE_CATALOG: Record { expect(settings.enableBetaSkills).toBe(false); }); - it("defaults apps off", () => { + it("defaults the retired Apps compatibility key on", () => { const settings = instanceExperimentalSettingsSchema.parse({}); - expect(settings.enableApps).toBe(false); + expect(settings.enableApps).toBe(true); }); it("accepts worktree run execution patches", () => { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 2ad7fb5a16..6a392d3f0d 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -45,7 +45,10 @@ export const instanceExperimentalSettingsSchema = z.object({ enableManagedSandboxOnly: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), - enableApps: z.boolean().default(false), + // Deprecated compatibility key. Apps is a standard product surface and is + // always enabled; this remains accepted so older stored rows and managed + // configs continue to load during upgrades. + enableApps: z.boolean().default(true), enablePipelines: z.boolean().default(false), enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index bb9859c4b1..6d496912d3 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -548,7 +548,8 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); }); - it("emits a stable code when an application name is already used", async () => { + it("automatically gives a new connection a distinct name when its default is already used", async () => { + installMcpOAuthFixture({ auth: "public" }); const company = await createCompany(db); await db.insert(toolApplications).values({ companyId: company.id, @@ -565,12 +566,19 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { const response = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) - .send({ link: "http://127.0.0.1:8848/mcp", name: "Taken fixture name" }) - .expect(409); + .send({ link: MCP_URL, name: "Taken fixture name" }) + .expect(201); - expect(response.body).toMatchObject({ - details: { code: "tool_access_name_conflict" }, - }); + expect(response.body.application.name).toBe("Taken fixture name (2)"); + expect(response.body.connection.name).toBe("Taken fixture name (2)"); + await expect( + db.select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, company.id)), + ).resolves.toEqual(expect.arrayContaining([ + { name: "Taken fixture name" }, + { name: "Taken fixture name (2)" }, + ])); }); it("emits deployment guidance without exposing server env-var names", async () => { diff --git a/server/src/__tests__/instance-settings-managed-overlay.test.ts b/server/src/__tests__/instance-settings-managed-overlay.test.ts index 7fcfd61c38..7def7195c3 100644 --- a/server/src/__tests__/instance-settings-managed-overlay.test.ts +++ b/server/src/__tests__/instance-settings-managed-overlay.test.ts @@ -11,8 +11,8 @@ const MANAGED_RAW = JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "2026.720.0", - // enableApps stored true in the DB gets forced off; enablePipelines has no - // stored value, so the overlay wins over the schema default (false). + // enableApps is retained only for compatibility and must be ignored; + // enablePipelines remains a live managed feature. features: { enableApps: false, enablePipelines: true }, plugins: { autoInstall: [] }, }); @@ -63,19 +63,17 @@ describe("applyManagedExperimentalOverlay", () => { expect(result.managedKeys).toEqual({}); }); - it("overlays managed values over stored values and records metadata", () => { + it("ignores the retired Apps flag while overlaying live managed values", () => { const config = parseManagedConfigEnv(managedEnv())!; const stored = normalizeExperimentalSettings({ enableApps: true }); const { experimental, managedKeys } = applyManagedExperimentalOverlay(stored, config); - // managed overlay > tenant DB value - expect(experimental.enableApps).toBe(false); + expect(experimental.enableApps).toBe(true); // managed overlay > schema default expect(experimental.enablePipelines).toBe(true); // unmanaged keys keep their stored/default values expect(experimental.enableCases).toBe(false); expect(managedKeys).toEqual({ - enableApps: { managed: true, managedBy: "paperclip-cloud" }, enablePipelines: { managed: true, managedBy: "paperclip-cloud" }, }); // input is not mutated @@ -96,10 +94,9 @@ describe("instanceSettingsService managed overlay", () => { const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() }); const experimental = await svc.getExperimental(); - expect(experimental.enableApps).toBe(false); + expect(experimental.enableApps).toBe(true); expect(experimental.enablePipelines).toBe(true); expect(experimental.managedKeys).toEqual({ - enableApps: { managed: true, managedBy: "paperclip-cloud" }, enablePipelines: { managed: true, managedBy: "paperclip-cloud" }, }); }); @@ -109,11 +106,8 @@ describe("instanceSettingsService managed overlay", () => { const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() }); const settings = await svc.get(); - expect(settings.experimental.enableApps).toBe(false); - expect(settings.experimental.managedKeys?.enableApps).toEqual({ - managed: true, - managedBy: "paperclip-cloud", - }); + expect(settings.experimental.enableApps).toBe(true); + expect(settings.experimental.managedKeys?.enableApps).toBeUndefined(); }); it("leaves the self-hosted read path unchanged (no managedKeys field)", async () => { @@ -137,8 +131,8 @@ describe("instanceSettingsService managed overlay", () => { expect(persistedSets).toHaveLength(1); const persisted = persistedSets[0]!.experimental as Record; - // The tenant's stored value survives in the DB even though the overlay - // masks it at read time — a later un-managing restores tenant intent. + // The retired compatibility key normalizes on, independent of the + // managed document's historical value. expect(persisted.enableApps).toBe(true); // The overlay-added value is not written. expect(persisted.enablePipelines).toBe(false); @@ -146,12 +140,9 @@ describe("instanceSettingsService managed overlay", () => { expect(persisted).not.toHaveProperty("managedKeys"); // The response still reflects the overlay. - expect(updated.experimental.enableApps).toBe(false); + expect(updated.experimental.enableApps).toBe(true); expect(updated.experimental.enablePipelines).toBe(true); - expect(updated.experimental.managedKeys?.enableApps).toEqual({ - managed: true, - managedBy: "paperclip-cloud", - }); + expect(updated.experimental.managedKeys?.enableApps).toBeUndefined(); }); it("does not let managed metadata leak into self-hosted writes", async () => { diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 8d306d0c10..569fb6685b 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -29,7 +29,7 @@ describe("instance settings service", () => { enableManagedSandboxOnly: false, enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: true, - enableApps: false, + enableApps: true, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableExternalObjects: false, @@ -59,10 +59,11 @@ describe("instance settings service", () => { }); }); - it("defaults enableApps to false for empty and legacy stored settings", () => { - expect(normalizeExperimentalSettings(undefined).enableApps).toBe(false); - expect(normalizeExperimentalSettings({}).enableApps).toBe(false); - expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(false); + it("keeps Apps on for empty, legacy, and explicitly disabled stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableApps).toBe(true); + expect(normalizeExperimentalSettings({}).enableApps).toBe(true); + expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(true); + expect(normalizeExperimentalSettings({ enableApps: false }).enableApps).toBe(true); }); it("retains the deprecated ingress key for stored-settings compatibility", () => { diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 809cdfb652..a329f14bf1 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -3670,6 +3670,8 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.status).toBe(200); expect(res.body.capabilities).toEqual({ + canCreateOrganizationGrant: true, + organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }); @@ -3903,11 +3905,59 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.status).toBe(200); expect(res.body.capabilities).toEqual({ + canCreateOrganizationGrant: false, + organizationGrantReason: "Only a company owner, administrator, or connection manager can share this credential with the organization.", canSetCompanyInstall: false, companyInstallReason: "Only someone who can configure this connection can choose this.", }); }); + it("requires connection-manager authority to create an organization credential", async () => { + const company = await createCompany(db); + const app = createRouteApp(db, boardSessionActor(company.id, "member")); + + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ galleryKey: "notion", grantKind: "organization" }) + .expect(403); + // Omitted grantKind retains the legacy organization default, so it must + // pass through the same authorization check. + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ galleryKey: "notion" }) + .expect(403); + + await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); + await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); + }); + + it("requires connection-manager authority to resume an organization credential", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const shared = await service.connectGalleryApp(company.id, { + galleryKey: "notion", + grantKind: "organization", + }, { actorType: "user", actorId: "member" }); + const app = createRouteApp(db, boardSessionActor(company.id, "member", "member")); + + // The retained shared identity wins over a contradictory personal value. + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ + galleryKey: "notion", + resumeConnectionId: shared.connectionId, + grantKind: "user", + }) + .expect(403); + + // Omitting the explicit id must not bypass the same check through the + // service's retained draft lookup by application name and source. + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ galleryKey: "notion", grantKind: "user" }) + .expect(403); + }); + it("previews remote mcp.json headers as secret replacement fields without echoing values", async () => { const company = await createCompany(db); const app = createRouteApp(db); @@ -5127,6 +5177,101 @@ describeEmbeddedPostgres("tool access service", () => { } }); + it("routes a managed Drive callback into the shared organization vault when selected", async () => { + const company = await createCompany(db); + const userId = `shared-drive-member-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const profile = "drive.read" as const; + const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const actor = { actorType: "user" as const, actorId: userId }; + const driveDefinition = getConnectableAppDefinition("google-drive")!; + const previousOwnershipAvailability = driveDefinition.ownershipAvailability; + driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true }; + mockToolsList([{ name: "search_files", annotations: { readOnlyHint: true } }]); + + try { + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-drive", + connectionMethodKey: "paperclip-read", + grantKind: "organization", + name: "Shared Drive managed read", + }, actor); + expect(connected.connection).toMatchObject({ credentialPolicy: "shared" }); + + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", + actor, + }); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, + claimId: "shared-drive-claim", + actor, + }); + + expect(completed.connection).toMatchObject({ + status: "active", + enabled: true, + credentialPolicy: "shared", + config: { + oauth: { + strategy: "paperclip_cloud_connector", + connectorSubjectUserId: userId, + }, + }, + }); + expect(completed.connection.credentialRefs).toEqual([ + expect.objectContaining({ + name: "oauth.access_token", + placement: "header", + key: "Authorization", + prefix: "Bearer ", + }), + ]); + expect(completed.connection.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual([ + "oauth.access_token", + "oauth.refresh_token", + ]); + + const grants = await db.select().from(connectionGrants).where(eq( + connectionGrants.connectionId, + connected.connectionId, + )); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + kind: "organization", + subjectUserId: null, + isDefault: true, + status: "active", + providerTenant: { + externalId: userId, + oauth: { strategy: "paperclip_cloud_connector" }, + }, + }); + expect(grants[0]!.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual( + completed.connection.credentialSecretRefs.map((ref) => ref.secretId).sort(), + ); + + const secrets = await db.select().from(companySecrets).where(inArray( + companySecrets.id, + completed.connection.credentialSecretRefs.map((ref) => ref.secretId), + )); + expect(secrets).toHaveLength(2); + expect(secrets.every((secret) => secret.scope === "company" && secret.ownerUserId === null)).toBe(true); + const bindings = await db.select().from(companySecretBindings).where(and( + eq(companySecretBindings.targetType, "tool_connection"), + eq(companySecretBindings.targetId, connected.connectionId), + )); + expect(bindings.map((binding) => binding.configPath).sort()).toEqual([ + "credentials.oauth.access_token", + "oauth.access_token", + "oauth.refresh_token", + ]); + } finally { + driveDefinition.ownershipAvailability = previousOwnershipAvailability; + } + }); + it("activates allowed Drive write actions with recommended approval defaults after a managed callback", async () => { const company = await createCompany(db); const userId = `drive-write-member-${randomUUID()}`; @@ -5671,7 +5816,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); const userId = `oauth-owner-${randomUUID()}`; - await grantBoardUser(db, company.id, userId, ["tools:use"]); + await grantBoardUser(db, company.id, userId, ["tools:use", "tools:manage_connections"]); const agent = await createAgent(db, company.id); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { @@ -5780,7 +5925,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "workspace-owner", []); + await grantBoardUser(db, company.id, "workspace-owner", ["tools:manage_connections"]); const agent = await createAgent(db, company.id); const { issue, run } = await createIssueAndRun(db, company.id, agent.id); const service = createTestToolAccessService(db); @@ -6439,7 +6584,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip-public.example"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board-user", []); + await grantBoardUser(db, company.id, "board-user", ["tools:manage_connections"]); const app = createRouteApp(db); const connectRes = await request(app) @@ -6555,6 +6700,7 @@ describeEmbeddedPostgres("tool access service", () => { }); it("normalizes a direct numeric loopback origin for OAuth when no public URL is configured", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); @@ -6573,6 +6719,7 @@ describeEmbeddedPostgres("tool access service", () => { }); it("does not derive an OAuth callback origin from a non-loopback request host", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); @@ -6591,6 +6738,48 @@ describeEmbeddedPostgres("tool access service", () => { }); }); + it("uses an authenticated same-origin HTTPS browser request without public URL config", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); + const company = await createCompany(db); + const app = createRouteApp(db, boardSessionActor(company.id, "owner"), undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + }); + + const connectRes = await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .set("Host", "paperclip.tail123.ts.net") + .set("Origin", "https://paperclip.tail123.ts.net") + .send({ galleryKey: "slack", name: "Tailscale Slack workspace" }); + + expect(connectRes.status).toBe(201); + expect(new URL(connectRes.body.auth.startUrl).searchParams.get("redirect_uri")).toBe( + "https://paperclip.tail123.ts.net/api/tools/oauth/callback", + ); + }); + + it("rejects a browser HTTPS origin that does not match the routed request host", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); + const company = await createCompany(db); + const app = createRouteApp(db, boardSessionActor(company.id, "owner"), undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + }); + + const connectRes = await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .set("Host", "paperclip.tail123.ts.net") + .set("Origin", "https://evil.example") + .send({ galleryKey: "slack", name: "Mismatched Slack workspace" }); + + expect(connectRes.status).toBe(422); + expect(connectRes.body).toMatchObject({ code: "oauth_redirect_origin_unsupported" }); + }); + it("requires non-viewer board access to start OAuth for active app connections", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); @@ -6729,7 +6918,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "oauth-operator", []); + await grantBoardUser(db, company.id, "oauth-operator", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator"); const connect = await service.connectGalleryApp( @@ -6776,6 +6965,25 @@ describeEmbeddedPostgres("tool access service", () => { .query({ state, code: "oauth-code" }) .expect(403); + await db.delete(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, "oauth-operator"), + eq(principalPermissionGrants.permissionKey, "tools:manage_connections"), + )); + await request(initiatingApp) + .get("/api/tools/oauth/callback") + .query({ state, code: "oauth-code" }) + .expect(403); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: "oauth-operator", + permissionKey: "tools:manage_connections", + scope: null, + grantedByUserId: "owner", + }); + await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1); vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { @@ -6954,7 +7162,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "supabase", @@ -7080,7 +7288,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "miro", @@ -7587,7 +7795,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const concurrentService = createTestToolAccessService(db); @@ -7693,7 +7901,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack invalid grant" }); const start = await service.startOAuth(company.id, connect.connectionId, { @@ -7784,7 +7992,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", @@ -7980,7 +8188,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack no refresh" }); const start = await service.startOAuth(company.id, connect.connectionId, { @@ -8696,7 +8904,7 @@ describeEmbeddedPostgres("tool access service", () => { await expect(db.select().from(toolConnections)).resolves.toHaveLength(1); }); - it("allows multiple same-named connections on one application", async () => { + it("automatically gives same-named connections distinct names", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ @@ -8722,10 +8930,41 @@ describeEmbeddedPostgres("tool access service", () => { expect(second.connectionId).not.toBe(first.connectionId); const rows = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, first.application.id)); expect(rows).toHaveLength(2); - expect(rows.map((row) => row.name)).toEqual(["Notion", "Notion"]); + expect(rows.map((row) => row.name).sort()).toEqual(["Notion", "Notion (2)"]); expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2); }); + it("automatically resolves same-name application races", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + mockToolsList([ + { + name: "read_items", + description: "Read items.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + ]); + + const results = await Promise.all([ + service.connectGalleryApp(company.id, { + link: "https://parallel-one.example.test/actions", + name: "Parallel app", + }, { actorType: "user", actorId: "board" }), + service.connectGalleryApp(company.id, { + link: "https://parallel-two.example.test/actions", + name: "Parallel app", + }, { actorType: "user", actorId: "board" }), + ]); + + expect(new Set(results.map((result) => result.application.id))).toHaveProperty("size", 2); + const applications = await db + .select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, company.id)); + expect(applications.map((row) => row.name).sort()).toEqual(["Parallel app", "Parallel app (2)"]); + }); + it("does not delete a reused application when the connect rolls back", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -8874,7 +9113,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_SECRET", "generic-client-secret"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board-user", []); + await grantBoardUser(db, company.id, "board-user", ["tools:manage_connections"]); const app = createRouteApp(db); const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); @@ -9057,7 +9296,7 @@ describeEmbeddedPostgres("tool access service", () => { it("starts OAuth only for the marked Smoke Lab HTTP fixture", async () => { const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const [application] = await db.insert(toolApplications).values({ companyId: company.id, diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index a6347b869e..e0f2cd606c 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -33,15 +33,21 @@ function trustedOriginsForRequest(req: Request) { return origins; } -function isTrustedBoardMutationRequest(req: Request) { +/** + * Return the browser origin only when it is the same origin Paperclip's CSRF + * guard accepts for this request. Callers may use this as browser-reachability + * evidence, but must still apply any protocol-specific constraints (for + * example OAuth requiring HTTPS outside loopback). + */ +export function trustedBoardMutationOrigin(req: Request): string | null { const allowedOrigins = trustedOriginsForRequest(req); const origin = parseOrigin(req.header("origin")); - if (origin && allowedOrigins.has(origin)) return true; + if (origin && allowedOrigins.has(origin)) return origin; const refererOrigin = parseOrigin(req.header("referer")); - if (refererOrigin && allowedOrigins.has(refererOrigin)) return true; + if (refererOrigin && allowedOrigins.has(refererOrigin)) return refererOrigin; - return false; + return null; } export function boardMutationGuard(): RequestHandler { @@ -68,7 +74,7 @@ export function boardMutationGuard(): RequestHandler { return; } - if (!isTrustedBoardMutationRequest(req)) { + if (!trustedBoardMutationOrigin(req)) { res.status(403).json({ error: "Board mutation requires trusted browser origin" }); return; } diff --git a/server/src/routes/tool-access-connection-intent.test.ts b/server/src/routes/tool-access-connection-intent.test.ts index ff5f76b6a5..9f438528f9 100644 --- a/server/src/routes/tool-access-connection-intent.test.ts +++ b/server/src/routes/tool-access-connection-intent.test.ts @@ -16,6 +16,24 @@ describe("Cloud connector enrollment return path", () => { "/QA%20%2F%20Apps/apps/connections?cloud_connector=enrolled", ); }); + + it("returns to the connector setup that started enrollment", () => { + expect(cloudConnectorEnrollmentReturnPath( + "APP", + "/apps/connect?source=google-drive&stage=setup", + )).toBe( + "/APP/apps/connect?source=google-drive&stage=setup&cloud_connector=enrolled", + ); + }); + + it("rejects external and unrelated enrollment return paths", () => { + expect(cloudConnectorEnrollmentReturnPath("APP", "https://evil.example/apps/connect")).toBe( + "/APP/apps/connections?cloud_connector=enrolled", + ); + expect(cloudConnectorEnrollmentReturnPath("APP", "/settings")).toBe( + "/APP/apps/connections?cloud_connector=enrolled", + ); + }); }); describe("connection intent OAuth callback document", () => { diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 22dc5d0a5f..b76638af13 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -71,6 +71,7 @@ import { oauthClientIdMetadataDocument, } from "../services/tool-access.js"; import { isLoopbackHost } from "../url-utils.js"; +import { trustedBoardMutationOrigin } from "../middleware/board-mutation-guard.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { redactRemoteUrlCredential } from "../services/remote-url-credentials.js"; import { wakeConnectionIntentAfterResolution } from "./connection-intents.js"; @@ -78,6 +79,8 @@ import type { heartbeatService } from "../services/heartbeat.js"; const COMPANY_INSTALL_DENIAL_REASON = "Only someone who can configure this connection can choose this."; +const ORGANIZATION_GRANT_DENIAL_REASON = + "Only a company owner, administrator, or connection manager can share this credential with the organization."; type Heartbeat = ReturnType; /** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */ @@ -180,8 +183,31 @@ export function connectionIntentOAuthOutcomeHtml(input: { return `Connection authorization

Returning to Paperclip…

`; } -export function cloudConnectorEnrollmentReturnPath(issuePrefix: string): string { - return `/${encodeURIComponent(issuePrefix)}/apps/connections?cloud_connector=enrolled`; +function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): string | null { + if (!returnTo || returnTo.length > 2_048) return null; + try { + const parsed = new URL(returnTo, "http://paperclip.local"); + if ( + parsed.origin !== "http://paperclip.local" + || parsed.pathname !== "/apps/connect" + || parsed.username + || parsed.password + ) return null; + return `${parsed.pathname}${parsed.search}`; + } catch { + return null; + } +} + +export function cloudConnectorEnrollmentReturnPath(issuePrefix: string, returnTo?: string | null): string { + const companyRoot = `/${encodeURIComponent(issuePrefix)}`; + const normalizedReturnTo = normalizeCloudConnectorEnrollmentReturnTo(returnTo); + if (normalizedReturnTo) { + const parsed = new URL(normalizedReturnTo, "http://paperclip.local"); + parsed.searchParams.set("cloud_connector", "enrolled"); + return `${companyRoot}${parsed.pathname}${parsed.search}`; + } + return `${companyRoot}/apps/connections?cloud_connector=enrolled`; } export function toolAccessRoutes( @@ -319,8 +345,62 @@ export function toolAccessRoutes( } } + function trustedBrowserBaseUrl(req: Request) { + const origin = trustedBoardMutationOrigin(req); + if (!origin) return null; + try { + const parsed = new URL(origin); + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const routedHost = forwardedHost || req.header("host")?.trim(); + const normalizedRoutedHost = routedHost + ? new URL(`${parsed.protocol}//${routedHost}`).host.toLowerCase() + : null; + if ( + parsed.host.toLowerCase() === normalizedRoutedHost + && (parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) + ) { + return parsed.origin; + } + } catch { + // The shared origin parser already validates this. Fail closed if its + // contract ever changes. + } + return null; + } + + function enrolledConnectorBaseUrl(req: Request) { + // Browser-initiated mutations must prove their own same-origin HTTPS + // request. The durable binding is only a callback/metadata fallback for + // provider GETs, which do not carry the initiating browser's Origin. + if (req.method !== "GET" && req.method !== "HEAD") return null; + const identity = loadPaperclipCloudConnectorIdentity(); + if (identity?.status !== "active") return null; + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const requestHost = (forwardedHost || req.header("host")?.trim())?.toLowerCase(); + if (!requestHost) return null; + for (const origin of identity.origins) { + try { + const parsed = new URL(origin); + if ( + parsed.protocol === "https:" + && !parsed.username + && !parsed.password + && parsed.host.toLowerCase() === requestHost + ) { + return parsed.origin; + } + } catch { + // Ignore malformed legacy identity origins. + } + } + return null; + } + function oauthRedirectUri(req: Request) { - const baseUrl = configuredPublicBaseUrl() ?? requestLoopbackBaseUrl(req); + const baseUrl = configuredPublicBaseUrl() + ?? trustedBrowserBaseUrl(req) + ?? enrolledConnectorBaseUrl(req) + ?? requestLoopbackBaseUrl(req); if (!baseUrl) { throw unprocessable( "This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.", @@ -331,6 +411,8 @@ export function toolAccessRoutes( } function oauthBrowserOrigin(req: Request) { + const trustedBrowserOrigin = trustedBrowserBaseUrl(req); + if (trustedBrowserOrigin) return trustedBrowserOrigin; const host = req.get("host")?.trim(); if (!host) return null; try { @@ -513,10 +595,12 @@ function connectorEnrollmentPrincipal(req: Request): string { req: Request, companyId: string, ): Promise { - const canSetCompanyInstall = await isToolConnectionManagerQuiet(req, companyId); + const canManageConnections = await isToolConnectionManagerQuiet(req, companyId); return { - canSetCompanyInstall, - companyInstallReason: canSetCompanyInstall ? null : COMPANY_INSTALL_DENIAL_REASON, + canCreateOrganizationGrant: canManageConnections, + organizationGrantReason: canManageConnections ? null : ORGANIZATION_GRANT_DENIAL_REASON, + canSetCompanyInstall: canManageConnections, + companyInstallReason: canManageConnections ? null : COMPANY_INSTALL_DENIAL_REASON, }; } @@ -749,6 +833,23 @@ function connectorEnrollmentPrincipal(req: Request): string { router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); + // An omitted grant kind is the backward-compatible organization default. + // On resume, the persisted connection identity is authoritative: accepting + // a contradictory `grantKind: "user"` here could otherwise let a creator + // replace the credential behind an existing organization grant. + const resumedConnection = req.body.resumeConnectionId + ? await svc.getConnection(req.body.resumeConnectionId, companyId) + : null; + const effectiveGrantKind = resumedConnection + ? resumedConnection.credentialPolicy === "per_user" ? "user" : "organization" + : req.body.grantKind ?? "organization"; + // Personal connection creation remains available to ordinary active + // members, but sharing a credential with every human is a manager + // operation and must be enforced here, not inferred by the client. + const createsOrganizationGrant = effectiveGrantKind === "organization"; + if (createsOrganizationGrant && !await isToolConnectionManagerQuiet(req, companyId)) { + throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON); + } try { const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req)); if (result.auth?.kind === "oauth") { @@ -863,6 +964,9 @@ function connectorEnrollmentPrincipal(req: Request): string { if (!companyId) throw badRequest("Paperclip Cloud enrollment requires a company"); assertCompanyAccess(req, companyId); const origin = new URL(oauthRedirectUri(req)).origin; + const returnTo = normalizeCloudConnectorEnrollmentReturnTo( + typeof req.body?.returnTo === "string" ? req.body.returnTo : undefined, + ) ?? undefined; let status; try { status = await startPaperclipCloudConnectorEnrollment({ @@ -870,6 +974,7 @@ function connectorEnrollmentPrincipal(req: Request): string { companyId, initiatedBy: connectorEnrollmentPrincipal(req), label: typeof req.body?.label === "string" ? req.body.label : undefined, + returnTo, }); } catch { throw unprocessable("Paperclip Cloud enrollment could not be started", { @@ -926,7 +1031,7 @@ function connectorEnrollmentPrincipal(req: Request): string { details: { environment: status.environment, status: status.status }, }); } - res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix)); + res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, pending?.returnTo)); }); const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => { @@ -1145,7 +1250,11 @@ function connectorEnrollmentPrincipal(req: Request): string { } const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId); const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId); - if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) { + if (!pendingState.subjectUserId) { + if (!await isToolConnectionManagerQuiet(req, pendingConnection.companyId)) { + throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON); + } + } else if (pendingState.subjectUserId === req.actor.userId) { await assertToolConnectionAccess(req, pendingConnection); } else { await assertToolConnectionConfigureAccess(req, pendingConnection); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index feb04ef33c..d590dde57e 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -224,7 +224,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, - enableApps: parsed.data.enableApps ?? false, + // Apps graduated from Experimental. Ignore historical off values while + // continuing to accept the compatibility key in stored settings. + enableApps: true, enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, @@ -260,7 +262,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, - enableApps: false, + enableApps: true, enablePipelines: false, enableCases: false, enableConferenceRoomChat: false, @@ -314,6 +316,9 @@ export function applyManagedExperimentalOverlay( for (const [key, value] of Object.entries(managedConfig.features) as Array< [ManagedExperimentalFeatureKey, boolean] >) { + // Existing Cloud stack configs may still carry enableApps. Accept the + // document during rollout, but never let the retired flag disable Apps. + if (key === "enableApps") continue; next[key] = value; managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY }; } diff --git a/server/src/services/paperclip-cloud-connector-enrollment.ts b/server/src/services/paperclip-cloud-connector-enrollment.ts index c5a2d0db74..103c34aedf 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.ts @@ -25,6 +25,7 @@ type PendingEnrollment = { expiresAt: string; companyId?: string; initiatedBy?: string; + returnTo?: string; }; export type PaperclipCloudConnectorIdentity = { @@ -132,6 +133,7 @@ export async function startPaperclipCloudConnectorEnrollment(input: { label?: string; companyId?: string; initiatedBy?: string; + returnTo?: string; env?: NodeJS.ProcessEnv; request?: typeof fetch; }): Promise { @@ -143,6 +145,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: { label?: string; companyId?: string; initiatedBy?: string; + returnTo?: string; env?: NodeJS.ProcessEnv; request?: typeof fetch; }): Promise { @@ -217,6 +220,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: { expiresAt: body.expiresAt, ...(input.companyId ? { companyId: input.companyId } : {}), ...(input.initiatedBy ? { initiatedBy: input.initiatedBy } : {}), + ...(input.returnTo ? { returnTo: input.returnTo } : {}), }, }; saveIdentity(identity); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index a21eee694a..89340a5d8b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -13,6 +13,7 @@ import { companyMemberships, companySecretBindings, companySecrets, + principalPermissionGrants, userSecretDefinitions, heartbeatRuns, issues, @@ -137,6 +138,7 @@ import { type OAuthEndpointUrlRejection, } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; +import { isUniqueViolation } from "../db-errors.js"; import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; import { @@ -189,6 +191,7 @@ type ActorInfo = { actorType?: "agent" | "user" | "system" | "plugin"; actorId?: string | null; sessionId?: string | null; + actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; }; const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]); @@ -8299,6 +8302,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return base.length <= 160 ? base : base.slice(0, 160); } + function nextAvailableConnectionName(requestedName: string, existingNames: readonly string[]): string { + const base = requestedName.trim() || "Custom app"; + const used = new Set(existingNames.map((candidate) => candidate.trim().toLocaleLowerCase())); + const unsuffixed = base.slice(0, 160); + if (!used.has(unsuffixed.toLocaleLowerCase())) return unsuffixed; + for (let index = 2; index < 10_000; index += 1) { + const suffix = ` (${index})`; + const candidate = `${base.slice(0, 160 - suffix.length).trimEnd()}${suffix}`; + if (!used.has(candidate.toLocaleLowerCase())) return candidate; + } + return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; + } + async function connectGalleryApp( companyId: string, input: ConnectToolApp, @@ -8369,7 +8385,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } } - const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); + const requestedName = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); // Compatibility for the original Sheets robot flow, whose clients predate // method selection and identify the method by its spreadsheet allowlist. const inferredMethodKey = !input.connectionMethodKey @@ -8429,11 +8445,64 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .limit(1) : [undefined]; const retainedConnection = requestedResumeConnection ?? recoveredConnection; + let applicationName = existingApplication?.name ?? requestedName; + let name = retainedConnection?.name ?? requestedName; + if (!existingApplication) { + const applicationNames = await db + .select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, companyId)); + applicationName = nextAvailableConnectionName(requestedName, applicationNames.map((row) => row.name)); + name = applicationName; + } else if (!retainedConnection) { + const connectionNames = await db + .select({ name: toolConnections.name }) + .from(toolConnections) + .where(and( + eq(toolConnections.companyId, companyId), + eq(toolConnections.applicationId, existingApplication.id), + )); + name = nextAvailableConnectionName(requestedName, connectionNames.map((row) => row.name)); + } const retainedGrantKind: ConnectionGrantKind | null = retainedConnection ? retainedConnection.credentialPolicy === "per_user" ? "user" : "organization" : null; + // The route can authorize an explicit resume before entering the service, + // but name/source recovery happens here. Do not let a caller submit a + // personal grant choice to pass the route and then inherit an implicitly + // recovered organization identity. Local implicit mode is already the + // unrestricted instance operator; every authenticated user must still hold + // current connection-manager authority before this retained row is touched. + if ( + retainedGrantKind === "organization" + && input.grantKind === "user" + && actor?.actorType === "user" + && actor.actorSource !== "local_implicit" + ) { + const actorUserId = actor.actorId; + const [membership] = actorUserId ? await db.select({ + membershipRole: companyMemberships.membershipRole, + }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actorUserId), + eq(companyMemberships.status, "active"), + )).limit(1) : []; + const roleCanManage = membership?.membershipRole === "owner" || membership?.membershipRole === "admin"; + const [explicitManagerGrant] = roleCanManage || !actorUserId ? [] : await db.select({ + id: principalPermissionGrants.id, + }).from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, actorUserId), + eq(principalPermissionGrants.permissionKey, "tools:manage_connections"), + )).limit(1); + if (!roleCanManage && !explicitManagerGrant) { + throw forbidden("Only a company owner, admin, or member with connection-manager permission can share credentials with the organization."); + } + } const requestedGrantKind = retainedGrantKind ?? input.grantKind ?? "organization"; if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) { throw badRequest(`${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`); @@ -8770,15 +8839,40 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} applicationRow = existingApplication; } } else { - [applicationRow] = await db.insert(toolApplications).values({ - companyId, - applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, - name, - description: safeApplicationDescription, - type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", - status: "draft", - metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" }, - }).returning(); + // Name selection is optimistic because multiple setup requests can + // legitimately begin at the same time. The company/name unique index + // is the authority: if another request wins after our read, refresh the + // names and retry with the next suffix instead of surfacing a conflict + // the user never asked to resolve. + for (let attempt = 0; attempt < 10 && !applicationRow; attempt += 1) { + try { + [applicationRow] = await db.insert(toolApplications).values({ + companyId, + applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, + name: applicationName, + description: safeApplicationDescription, + type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", + status: "draft", + metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" }, + }).returning(); + } catch (error) { + if (!isUniqueViolation(error, "tool_applications_company_name_uq")) throw error; + const applicationNames = await db + .select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, companyId)); + applicationName = nextAvailableConnectionName( + requestedName, + applicationNames.map((row) => row.name), + ); + name = applicationName; + } + } + if (!applicationRow) { + throw conflict("Paperclip could not allocate a unique connection name", { + code: "tool_access_name_allocation_exhausted", + }); + } } await assertSecretRefs(companyId, [...credentialRefs, ...credentialSecretRefs]); @@ -10181,13 +10275,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (!consumedState) throw badRequest("OAuth state was not found, expired, or has already been used"); const txSecrets = secretService(tx); const txSecretContext = { dbClient: tx, secretClient: txSecrets }; - const [existingUserGrant] = await tx.select().from(connectionGrants).where(and( + const personalCredential = connection.credentialPolicy === "per_user"; + const [existingCredentialGrant] = await tx.select().from(connectionGrants).where(and( eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, subjectUserId), + eq(connectionGrants.kind, personalCredential ? "user" : "organization"), + personalCredential + ? eq(connectionGrants.subjectUserId, subjectUserId) + : eq(connectionGrants.isDefault, true), )).limit(1); - const existingRefs = existingUserGrant?.credentialSecretRefs ?? []; + const existingRefs = existingCredentialGrant?.credentialSecretRefs + ?? (personalCredential ? [] : connection.credentialSecretRefs); const accessRef = await createOrRotateOAuthSecret({ companyId: connection.companyId, connection, @@ -10196,7 +10294,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} value: credentials.accessToken, actor: input.actor, existingRefs, - ownerUserId: subjectUserId, + ownerUserId: personalCredential ? subjectUserId : undefined, }, txSecretContext); const refreshRef = await createOrRotateOAuthSecret({ companyId: connection.companyId, @@ -10206,7 +10304,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} value: refreshToken, actor: input.actor, existingRefs, - ownerUserId: subjectUserId, + ownerUserId: personalCredential ? subjectUserId : undefined, }, txSecretContext); const credentialSecretRefs = [ ...existingRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"), @@ -10231,16 +10329,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} revokedByUserId: null, updatedAt: now(), }; - if (existingUserGrant) { - await tx.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingUserGrant.id)); + if (existingCredentialGrant) { + await tx.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingCredentialGrant.id)); } else { await tx.insert(connectionGrants).values({ companyId: connection.companyId, connectionId: connection.id, - kind: "user", - subjectUserId, + kind: personalCredential ? "user" : "organization", + subjectUserId: personalCredential ? subjectUserId : null, ...grantValues, - isDefault: false, + isDefault: !personalCredential, createdByUserId: subjectUserId, }); } @@ -10262,13 +10360,31 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} authKind: "oauth", config: nextConfig, transportConfig: nextConfig, + credentialRefs: personalCredential + ? connection.credentialRefs.filter((ref) => ref.name !== "oauth.access_token") + : [ + ...connection.credentialRefs.filter((ref) => ref.name !== "oauth.access_token"), + { + name: "oauth.access_token", + secretId: accessRef.secretId, + version: "latest" as const, + placement: "header" as const, + key: "Authorization", + prefix: "Bearer ", + }, + ], + credentialSecretRefs: personalCredential + ? connection.credentialSecretRefs.filter( + (ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token", + ) + : credentialSecretRefs, updatedAt: now(), }).where(eq(toolConnections.id, connection.id)).returning(); await tx.update(toolApplications).set({ status: shouldFinalizeManagedDefaults ? "draft" : "active", updatedAt: now(), }).where(eq(toolApplications.id, connection.applicationId)); - await syncCredentialBindings(connection, credentialSecretRefs, tx); + await syncCredentialBindings(connection, personalCredential ? credentialSecretRefs : [], tx); const linkedInteractionKind = stateRow.interactionId ? await tx .select({ kind: issueThreadInteractions.kind }) @@ -10737,7 +10853,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} // Keep callback persistence serialized with membership suspension, role // downgrade, and removal. Once this row is locked, authority cannot be // revoked between the live check and the shared credential/grant writes. - const [membership] = await tx.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + const [membership] = await tx.select({ + id: companyMemberships.id, + membershipRole: companyMemberships.membershipRole, + }).from(companyMemberships).where(and( eq(companyMemberships.companyId, connection.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, organizationActorUserId), @@ -10747,6 +10866,18 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (!membership) { throw forbidden("Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again."); } + const roleCanManage = membership.membershipRole === "owner" || membership.membershipRole === "admin"; + const [explicitManagerGrant] = roleCanManage ? [] : await tx.select({ + id: principalPermissionGrants.id, + }).from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, connection.companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, organizationActorUserId), + eq(principalPermissionGrants.permissionKey, "tools:manage_connections"), + )).limit(1).for("update"); + if (!roleCanManage && !explicitManagerGrant) { + throw forbidden("Only a company owner, admin, or member with connection-manager permission can share credentials with the organization."); + } const txSecrets = secretService(tx); const txSecretContext = { dbClient: tx, secretClient: txSecrets }; diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts index 4b448eab64..71a93dbf8d 100644 --- a/tests/e2e/app-not-connected.spec.ts +++ b/tests/e2e/app-not-connected.spec.ts @@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise { await page.getByRole("button", { name: "Reconnect", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 }); await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 }); + await page.getByRole("button", { name: "Save and continue" }).click(); await expect(page.getByText(mock.url)).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true }); diff --git a/tests/e2e/application-delete-screenshot.spec.ts b/tests/e2e/application-delete-screenshot.spec.ts index cfb15c7acb..960373877a 100644 --- a/tests/e2e/application-delete-screenshot.spec.ts +++ b/tests/e2e/application-delete-screenshot.spec.ts @@ -4,9 +4,6 @@ import { expect, test } from "@playwright/test"; // table now redirects into Apps, so capture the current app removal // confirmation on the app Advanced tab instead. test("captures the current app removal confirmations", async ({ page }) => { - const flags = await page.request.patch("/api/instance/settings/experimental", { data: { enableApps: true } }); - expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true); - const companyRes = await page.request.post("/api/companies", { data: { name: `PAP-10817 remove app ${Date.now()}` }, }); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index c08fdad975..9e9a64f6af 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -18,8 +18,6 @@ async function discoverCompany(request: APIRequestContext): Promise }); expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true); const company = await res.json(); - const flags = await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } }); - expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true); return { companyId: company.id, prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E", diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index c98d055592..f1a5282569 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise { await linkInput.fill(mock.url); await page.getByRole("button", { name: "Continue" }).click(); + // Access is chosen before credentials so the user knows who and which + // agents will receive the connection before Paperclip contacts it. + await expect(page.getByText("Which humans can use this credential?")).toBeVisible(); + await page.getByRole("button", { name: "Save and continue" }).click(); + // LinkKey step keeps the BYO connection heading. Mock doesn't // require a key — leave the default "No" answer. await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 }); diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts index b45e23a515..c2d180e9c9 100644 --- a/tests/e2e/connection-intents.spec.ts +++ b/tests/e2e/connection-intents.spec.ts @@ -22,11 +22,6 @@ async function newCompany(request: APIRequestContext): Promise { data: { name: `Connection intent E2E ${Date.now()}` }, }), ); - await json( - await request.patch("/api/instance/settings/experimental", { - data: { enableApps: true }, - }), - ); return { companyId: company.id, prefix: company.issuePrefix }; } @@ -221,6 +216,7 @@ test("store setup and task connection intent share one fake provider through con .getByPlaceholder("https://example.com/actions") .fill(provider.url); await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Save and continue" }).click(); await page.getByRole("button", { name: /Check link/i }).click(); // A no-auth read-only provider can complete the access/install defaults in // one commit. Other methods exercise the same intermediate steps in the diff --git a/tests/e2e/mcp-user-stories.spec.ts b/tests/e2e/mcp-user-stories.spec.ts index 2a42dffd8c..60e6ee565d 100644 --- a/tests/e2e/mcp-user-stories.spec.ts +++ b/tests/e2e/mcp-user-stories.spec.ts @@ -24,7 +24,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise( await request.post("/api/companies", { data: { name: `MCP US ${label} ${Date.now()}` } }), ); - await json(await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } })); return { companyId: body.id, prefix: body.issuePrefix ?? body.prefix ?? body.urlKey ?? "E2E" }; } diff --git a/tests/e2e/smoke-lab.shared.ts b/tests/e2e/smoke-lab.shared.ts index 1cb8eaf75b..4e91bbee60 100644 --- a/tests/e2e/smoke-lab.shared.ts +++ b/tests/e2e/smoke-lab.shared.ts @@ -82,7 +82,7 @@ async function createScout(request: APIRequestContext, companyId: string): Promi } async function enableSmokeLab(request: APIRequestContext) { - await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true, enableApps: true } })); + await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true } })); } async function createSmokeRun(request: APIRequestContext, companyId: string, scenarioCount: number) { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 23b9bd9b2d..803188021c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,7 +9,6 @@ import { TaskChatLab } from "./pages/TaskChatLab"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate"; -import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; import { CloudManagedPageGate } from "./components/CloudManagedPageGate"; import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate"; import { IsolatedWorkspacesRouteGate } from "./components/IsolatedWorkspacesRouteGate"; @@ -156,37 +155,35 @@ function boardRoutes() { } /> } /> } /> - }> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - {/* Connector health is inline on the Apps landing page; keep legacy links working. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + {/* Connector health is inline on the Apps landing page; keep legacy links working. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> }> } /> diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 78a252c377..fb66a3326f 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -289,8 +289,8 @@ export type ToolPolicyTestResponse = { export const toolsApi = { getCloudConnectorEnrollment: () => api.get("/tools/oauth/cloud-connector/enrollment"), - startCloudConnectorEnrollment: (companyId: string, label?: string) => - api.post("/tools/oauth/cloud-connector/enrollment", { companyId, label }), + startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) => + api.post("/tools/oauth/cloud-connector/enrollment", { companyId, label, returnTo }), // --- Applications --- listGallery: (companyId: string) => api.get(`/companies/${companyId}/tools/gallery`), diff --git a/ui/src/components/AppsExperimentalGate.test.tsx b/ui/src/components/AppsExperimentalGate.test.tsx deleted file mode 100644 index 5a386cac89..0000000000 --- a/ui/src/components/AppsExperimentalGate.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// @vitest-environment jsdom - -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AppsExperimentalGate } from "./AppsExperimentalGate"; - -const mockInstanceSettingsApi = vi.hoisted(() => ({ - getExperimental: vi.fn(), -})); - -vi.mock("@/api/instanceSettings", () => ({ - instanceSettingsApi: mockInstanceSettingsApi, -})); - -vi.mock("@/lib/router", () => ({ - Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( -
- ), - Outlet: () =>
Apps content
, -})); - -async function flushReact() { - for (let index = 0; index < 5; index += 1) { - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - } - flushSync(() => {}); -} - -describe("AppsExperimentalGate", () => { - let container: HTMLDivElement; - let root: Root | null = null; - - async function renderGate() { - root = createRoot(container); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - flushSync(() => { - root!.render( - - - , - ); - }); - await flushReact(); - } - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - }); - - afterEach(() => { - flushSync(() => root?.unmount()); - root = null; - container.remove(); - vi.clearAllMocks(); - }); - - it("redirects to the dashboard when apps are disabled", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); - await renderGate(); - - expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe("/dashboard"); - expect(container.querySelector('[data-testid="apps-content"]')).toBeNull(); - }); - - it("renders apps routes when apps are enabled", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); - await renderGate(); - - expect(container.querySelector('[data-testid="apps-content"]')).not.toBeNull(); - expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); - }); - - it("renders nothing while the flag is loading", async () => { - mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); - await renderGate(); - - expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); - expect(container.querySelector('[data-testid="apps-content"]')).toBeNull(); - }); -}); diff --git a/ui/src/components/AppsExperimentalGate.tsx b/ui/src/components/AppsExperimentalGate.tsx deleted file mode 100644 index 3bbe1eda83..0000000000 --- a/ui/src/components/AppsExperimentalGate.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Navigate, Outlet } from "@/lib/router"; -import { useAppsEnabled } from "@/hooks/useAppsEnabled"; - -export function AppsExperimentalGate() { - const { enabled, loaded } = useAppsEnabled(); - - if (!loaded) return null; - if (!enabled) return ; - return ; -} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 6dbda8fe0c..5596289aea 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -565,7 +565,7 @@ describe("Layout", () => { }); }); - it("does not mount the Apps secondary sidebar while experimental apps are disabled", async () => { + it("mounts the Apps secondary sidebar regardless of the retired experimental flag", async () => { currentPathname = "/PAP/apps"; mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); const root = createRoot(container); @@ -583,9 +583,9 @@ describe("Layout", () => { await flushReact(); await flushReact(); - expect(container.textContent).not.toContain("Apps sidebar"); + expect(container.textContent).toContain("Apps sidebar"); expect(container.textContent).toContain("Main company nav"); - expect(mockSetForceCollapsed).toHaveBeenCalledWith(false); + expect(mockSetForceCollapsed).toHaveBeenCalledWith(true); await act(async () => { root.unmount(); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 4b5528121d..898f715955 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -29,7 +29,6 @@ import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; -import { useAppsEnabled } from "../hooks/useAppsEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -113,7 +112,6 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); - const { enabled: appsEnabled } = useAppsEnabled(); const isCompanySettingsRoute = [ "/company/settings", "/company/export", @@ -176,11 +174,11 @@ export function Layout() { // both desktop (SecondarySidebar) and mobile (off-canvas drawer). const secondarySidebar = isCompanySettingsRoute ? ( - ) : appsEnabled && appDetailConnectionId ? ( + ) : appDetailConnectionId ? ( - ) : appsEnabled && appDetailApplicationId ? ( + ) : appDetailApplicationId ? ( - ) : appsEnabled && (isAppsRoute || isToolsRoute) ? ( + ) : isAppsRoute || isToolsRoute ? ( ) : routeSidebarSlot ? ( { }); }); - it("hides the Apps nav item unless experimental apps are enabled", async () => { + it("always shows Apps at the bottom of the Work section", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); - const disabledRoot = await renderSidebar(); + const root = await renderSidebar(); - expect([...container.querySelectorAll("a")].some((anchor) => anchor.textContent === "Apps")).toBe(false); - - flushSync(() => { - disabledRoot.unmount(); - }); - - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); - const enabledRoot = await renderSidebar(); - - const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Apps"); + const links = [...container.querySelectorAll("a")]; + const link = links.find((anchor) => anchor.textContent === "Apps"); expect(link?.getAttribute("href")).toBe("/apps"); + expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan( + links.findIndex((anchor) => anchor.textContent === "Projects"), + ); + expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan( + links.findIndex((anchor) => anchor.textContent === "Org"), + ); flushSync(() => { - enabledRoot.unmount(); + root.unmount(); }); }); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index f54b3dec79..2281375062 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -83,7 +83,6 @@ export function Sidebar() { usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; - const showApps = experimentalSettings?.enableApps === true; const showPipelines = experimentalSettings?.enablePipelines === true; const showStatusCards = experimentalSettings?.enableStatusCards === true; const goalsLinkPending = experimentalSettings === undefined; @@ -266,6 +265,7 @@ export function Sidebar() { className="flex flex-col gap-0.5" itemClassName="text-(length:--text-compact) font-medium" /> + {/* Classic mode restores the per-project collapsible below Work. */} @@ -275,7 +275,6 @@ export function Sidebar() { - {showApps ? : null} {/* One entry — /audit merged into the rich Activity feed (PAP-16302). */} diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 876f2737f6..1f94c056d0 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -7,6 +7,7 @@ import { Check, ChevronDown, ChevronRight, + Cloud, Copy, Link2, Loader2, @@ -209,10 +210,10 @@ const STEP_INDEX: Record, number> = { key: 2, }; const ZAPIER_STEP_INDEX: Record, number> = { - key: 0, - access: 1, + access: 0, + key: 1, }; -const ZAPIER_STEP_LABELS = ["Add MCP URL"]; +const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"]; /** * Which identity a fresh connection should default to (PAP-17835). @@ -437,7 +438,9 @@ export function ConnectionSetupFlow({ }; }); - const [step, setStep] = useState(requestedAppKey || prefill.link || zapierSource ? "key" : "gallery"); + const [step, setStep] = useState( + requestedAppKey ? "key" : prefill.link || zapierSource ? "access" : "gallery", + ); const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); const [linkUrl, setLinkUrl] = useState(prefill.link); @@ -577,7 +580,7 @@ export function ConnectionSetupFlow({ resetGenericAuthState(); setCredentials({}); setConnectResult(null); - setStep("key"); + setStep("access"); navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId)); return; } @@ -629,18 +632,75 @@ export function ConnectionSetupFlow({ useEffect(() => { if (host !== "page") return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: vercelConnectMode ? "Vercel Connect" : byoOnly ? "Connect your own tool" : "Connect an app" }, ]); return () => setBreadcrumbs([]); - }, [byoOnly, host, setBreadcrumbs, selectedCompany?.name, vercelConnectMode]); + }, [byoOnly, host, setBreadcrumbs, vercelConnectMode]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const fullRequestedDefinition = requestedAppKey + ? getConnectableAppDefinition(requestedAppKey) + : null; + const requestedDefinitionUsesManagedConnector = Boolean( + fullRequestedDefinition?.methods.some((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + || candidate.oauthStrategy === "paperclip_id_connector" + ), + ); + const entryAdvertisesManagedConnector = Boolean( + entry?.methods.some((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + || candidate.oauthStrategy === "paperclip_id_connector" + ), + ); + const connectorEnrollmentQuery = useQuery({ + queryKey: ["cloud-connector", "enrollment"], + queryFn: () => toolsApi.getCloudConnectorEnrollment(), + enabled: Boolean( + selectedCompanyId + && requestedDefinitionUsesManagedConnector + && !entryAdvertisesManagedConnector + ), + }); + const [connectorEnrollmentError, setConnectorEnrollmentError] = useState(null); + const openConnectorEnrollment = useCallback((verificationUrl: string) => { + const target = resolveAuthorizationTarget(verificationUrl); + if (!target.ok) { + setConnectorEnrollmentError(target.message); + return; + } + navigateTopLevel(target.url); + }, []); + const startConnectorEnrollment = useMutation({ + mutationFn: () => toolsApi.startCloudConnectorEnrollment( + selectedCompanyId!, + selectedCompany?.name, + requestedAppKey + ? appConnectHref(requestedAppKey, "key", credentialSource, { + resumeConnectionId, + reconnectConnectionId, + interactionId: connectionIntentId, + }) + : undefined, + ), + onSuccess: (status) => { + if (!status.verificationUrl) { + setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again."); + return; + } + openConnectorEnrollment(status.verificationUrl); + }, + onError: (error) => { + setConnectorEnrollmentError( + error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.", + ); + }, + }); const applicationsQuery = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listApplications(selectedCompanyId!), @@ -1414,6 +1474,18 @@ export function ConnectionSetupFlow({ && (directOAuthEntry || oauthPhase !== "entry"), ); + const showConnectorEnrollmentStep = Boolean( + step === "key" + && entry + && requestedDefinitionUsesManagedConnector + && !entryAdvertisesManagedConnector + && ( + connectorEnrollmentQuery.isLoading + || connectorEnrollmentQuery.isError + || connectorEnrollmentQuery.data?.configured !== true + ) + ); + if (showCuratedOAuthState && automaticOAuthEntry) { return ( )} - {step === "key" && entry && ( + {step === "key" && entry && showConnectorEnrollmentStep ? ( +
+
+
+ +
+
+

+ Connect with Paperclip +

+
+
+ + {connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( + + {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} + + ) : null} + +
+ + +
+
+ ) : step === "key" && entry ? ( - )} + ) : null} {step === "key" && !entry && linkUrl && !zapierSource && ( { setLinkNeedsKey(next); @@ -1746,7 +1855,7 @@ export function ConnectionSetupFlow({ matchedEntry={linkMatchedEntry} onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined} submitting={connectMutation.isPending || genericOAuthPending} - onBack={backToGallery} + onBack={() => setStep("access")} onConnect={() => { setLinkGuidance(null); connectMutation.mutate(undefined); @@ -1759,7 +1868,7 @@ export function ConnectionSetupFlow({ link={linkUrl} onLinkChange={setLinkUrl} submitting={connectMutation.isPending} - onBack={backToGallery} + onBack={() => setStep("access")} onConnect={() => connectMutation.mutate(undefined)} /> )} @@ -1848,7 +1957,7 @@ function StepHeader({ ) : null}

- {appIdentity ? `Connect ${appIdentity.name}` : "Connect an app"} + {appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}

{subtitle}

{unverifiedHost ? : null} @@ -1943,7 +2052,7 @@ export function OAuthConnectStateScreen({ unverifiedHost={unverifiedHost} onCancel={onCancel} /> -
+
{phase === "error" ? ( @@ -1975,10 +2084,6 @@ export function OAuthConnectStateScreen({ )}
-

- - Your authorization stays in Paperclip’s encrypted secret store. -

); @@ -2002,20 +2107,8 @@ function ZapierConnectStep({ const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com"); return ( -
-
- - - -
-

Connect Zapier

-

- Paste the complete MCP URL Zapier gives you, including its token. -

-
-
- -
+
+
-

- The token is part of the URL. Paperclip stores it securely and checks the connection before enabling actions. -

{link.trim() && !isZapierLink && (

Paste a valid Zapier URL to continue.

)}
-
+
@@ -2327,7 +2417,6 @@ function normalizeAppLink(value: string): string | null { function LinkConnectStep({ link, name, - onNameChange, needsKey, onNeedsKeyChange, keyValue, @@ -2351,7 +2440,6 @@ function LinkConnectStep({ }: { link: string; name: string; - onNameChange: (next: string) => void; needsKey: boolean; onNeedsKeyChange: (next: boolean) => void; keyValue: string; @@ -2388,28 +2476,17 @@ function LinkConnectStep({ }; const canSubmit = canSubmitGenericConnect(draft); const showSimpleKeyQuestion = authMode === "auto"; - const nameInputRef = useRef(null); const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link; - useEffect(() => { - if (guidance?.focus === "name") nameInputRef.current?.focus(); - }, [guidance]); - const updateHeader = (id: string, patch: Partial) => { onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row))); }; return ( -
-
- - - -
-

Connect your own MCP server

-

{displayedLink}

- -
+
+
+

{displayedLink}

+
{matchedEntry && onUseMatchedEntry ? ( @@ -2432,22 +2509,7 @@ function LinkConnectStep({
) : null} -
-
- - onNameChange(e.target.value)} - placeholder="My app" - className="mt-2 h-11" - /> -

- We filled this in from the address. Change it if you'd like. -

-
- +
{showSimpleKeyQuestion && (
@@ -2485,7 +2547,6 @@ function LinkConnectStep({ className="mt-2 h-11 font-mono" />
-
) : null} @@ -2553,7 +2614,6 @@ function LinkConnectStep({ Add another header {headerError ?

{headerError}

: null} -
) : null} @@ -2590,7 +2650,6 @@ function LinkConnectStep({ className="mt-2 h-11 font-mono" />
-
) : null} @@ -2601,15 +2660,10 @@ function LinkConnectStep({ -
- - We'll check the server before turning anything on. - - -
+
); @@ -2649,21 +2703,6 @@ const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string }, ]; -function StoredSecurelyNote() { - return ( -
- -
-
Stored securely.
-
- Paperclip keeps this in its encrypted secret store. You can replace it anytime from this app's page, - but it can't be read back. -
-
-
- ); -} - function SegmentedOption({ label, selected, @@ -2690,44 +2729,8 @@ function SegmentedOption({ ); } -function ConnectionNameField({ - name, - onNameChange, -}: { - name: string; - onNameChange: (next: string) => void; -}) { - return ( -
- - onNameChange(e.target.value)} - placeholder="My app" - className="mt-2 h-11" - /> -

- We filled this in from the app. Change it to tell connections apart. -

-
- ); -} - -function connectionMethodAuthenticationLabel(method: ConnectionMethodDef): string { - if (method.auth === "api_key") return "Authentication: API key"; - if (method.auth === "none") return "Authentication: no credentials"; - if (connectionMethodSupportsAutomaticOAuth(method)) { - return connectionMethodAcceptsCustomerOAuthClient(method) - ? "Authentication: browser sign-in or your OAuth app" - : "Authentication: browser sign-in"; - } - return "Authentication: your OAuth app"; -} - function KeyStep({ entry, - name, - onNameChange, values, onChange, oauthClientId, @@ -2750,8 +2753,6 @@ function KeyStep({ onConnect, }: { entry: AppDefinition; - name: string; - onNameChange: (next: string) => void; values: Record; onChange: (next: Record) => void; oauthClientId: string; @@ -2777,7 +2778,6 @@ function KeyStep({ onBack: () => void; onConnect: () => void; }) { - const copy = appCopyFor(entry.slug, entry.description); const methods = useMemo( () => connectionMethodsForCredentialSource(entry, credentialSource), [credentialSource, entry], @@ -2908,7 +2908,6 @@ function KeyStep({ options={capabilityMethods.map((candidate) => ({ value: candidate.key, title: candidate.label ?? (candidate.auth === "oauth" ? `Sign in with ${entry.name}` : "Use an API key"), - description: `${connectionMethodAuthenticationLabel(candidate)}. ${candidate.whenToUse}`, }))} /> {!method &&

Choose a connection method to continue.

} @@ -2919,18 +2918,9 @@ function KeyStep({ const parsed = parseGoogleSheetIds(googleSheetsLinks); const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0; return ( -
-
- -
-

Connect Google Sheets

-

{copy.short}

-
-
- -
+
+
{capabilitySelection} - {robotEmail ? (
@@ -2992,43 +2982,28 @@ function KeyStep({ ); } - return ( -
-
- -
-

Connect {entry.name}

-

{copy.short}

-
-
+ const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl; -
+ return ( +
+ {requirementsUrl ? ( + + ) : null} + +
{capabilitySelection} {authenticationSelection} - {method?.warnings?.map((warning) => ( - - {warning} - - ))} - - {method ? ( -
-

{method.guidanceMd}

- {entry.docsUrl || method.consoleLinks?.docs ? ( - - Review {entry.name} setup requirements - - - ) : null} -
- ) : null} - {usingVercel && vercelReview && vercelConnectAvailability ? (
@@ -3065,8 +3040,6 @@ function KeyStep({
) : null} - - {standardConfigFields.map((field) => ( ) : null} - {usingVercel ? null : method && fields.length === 0 ? ( -

- {method.auth === "oauth" - ? `You’ll continue to ${entry.name} to sign in securely.` - : "This app doesn’t need a key. Just connect to continue."} -

- ) : ( + {usingVercel || !method || fields.length === 0 ? null : ( fields.map((field) => (
-
- - {method?.auth === "oauth" - ? "You’ll sign in before anything turns on." - : "We’ll check the key before turning anything on."} - - -
+
); @@ -3286,7 +3235,6 @@ function OAuthClientFields({ className="mt-2 h-11 font-mono" />
-
); } @@ -3380,7 +3328,8 @@ export function AccessStep({ setInstallAgentIds: (ids: Set) => void; /** Task-hosted intents grant reach only to the agent that requested it. */ lockedAgentId?: string; - capabilities?: Pick & { + capabilities?: Pick & { + organizationGrantReason?: string | null; companyInstallReason?: string | null; editableAgentIds?: string[]; } | null; @@ -3409,15 +3358,19 @@ export function AccessStep({ // available the option stays visible and disabled with the reason, so the // scope stays legible instead of quietly disappearing. const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true; + const canCreateOrganizationGrant = capabilities?.canCreateOrganizationGrant ?? true; const needsIdentityChoice = authKind !== "none"; const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]); - const canContinue = preserveAgentAccess + const identityChoiceAllowed = !needsIdentityChoice + || grantKind === "user" + || canCreateOrganizationGrant; + const canContinue = identityChoiceAllowed && (preserveAgentAccess ? true : lockedAgentId ? installAgentIds.has(lockedAgentId) : installChoice === "all" ? canSetCompanyInstall - : installAgentIds.size > 0; + : installAgentIds.size > 0); const lockedAgentName = lockedAgentId ? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent" : null; @@ -3460,6 +3413,15 @@ export function AccessStep({ value: "organization", title: "Any human in the company", icon:
- toggleMutation.mutate({ enableApps: checked })} - disabled={toggleMutation.isPending} - settingKey="enableApps" - managed={managedKeys.enableApps} - ariaLabel="Toggle apps experimental setting" - /> - { if (!activeTab) return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: appName, href: appTabHref(connectionId, "setup") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name, appName, connectionId, activeTab]); + }, [setBreadcrumbs, appName, connectionId, activeTab]); const catalog = catalogQuery.data?.catalog ?? []; const profile = useMemo( diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx index 47ab3a73b3..ba0a5c8565 100644 --- a/ui/src/pages/apps/AppNotConnected.tsx +++ b/ui/src/pages/apps/AppNotConnected.tsx @@ -42,7 +42,7 @@ export function AppNotConnected() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null; @@ -115,13 +115,12 @@ export function AppNotConnected() { useEffect(() => { if (!activeTab) return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: appName, href: appApplicationTabHref(applicationId, "setup") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name, appName, applicationId, activeTab]); + }, [setBreadcrumbs, appName, applicationId, activeTab]); const remove = useMutation({ mutationFn: () => toolsApi.updateApplication(applicationId, { status: "archived" }), diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index d56c9ebb88..a27733c097 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -17,6 +17,8 @@ const connectAppMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); const finishAppMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); +const getCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); +const startCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const navigateTopLevelMock = vi.hoisted(() => vi.fn()); @@ -31,6 +33,7 @@ const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog" const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!; const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; +const GOOGLE_CALENDAR = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-calendar")!; const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!; const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!; const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!; @@ -46,6 +49,9 @@ vi.mock("@/api/tools", () => ({ finishAppMock(companyId, connectionId, input), putConnectionInstalls: (connectionId: string, installs: unknown) => putConnectionInstallsMock(connectionId, installs), + getCloudConnectorEnrollment: () => getCloudConnectorEnrollmentMock(), + startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) => + startCloudConnectorEnrollmentMock(companyId, label, returnTo), }, })); @@ -176,6 +182,7 @@ async function gotoLinkFrame(container: HTMLDivElement, url: string) { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); + await passAccessStep(); } describe("AppsConnect — Connect with a link (M4 frame)", () => { @@ -195,6 +202,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { GITHUB, ], capabilities: { + canCreateOrganizationGrant: true, + organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }, @@ -209,6 +218,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); finishAppMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); + getCloudConnectorEnrollmentMock.mockResolvedValue({ + configured: true, + status: "active", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: ["https://paperclip.example.test"], + }); + startCloudConnectorEnrollmentMock.mockResolvedValue({ + configured: false, + status: "pending", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: [], + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", + }); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "example.com" }, @@ -264,7 +290,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(document.activeElement).toBe(urlInput); }); - it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => { + it("an unrecognized URL routes to a minimal frame with the URL and key choice", async () => { await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); @@ -277,11 +303,29 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(buttonByText("No")).toBeTruthy(); expect(buttonByText("Yes")).toBeTruthy(); - // Name is auto-filled from the host with www. stripped. - const nameInput = Array.from(container.querySelectorAll("input")).find( - (i) => i.getAttribute("placeholder") === "My app", - ); - expect(nameInput?.value).toBe("example.com/actions"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); + }); + + it("opens the shared Access step before configuring a pasted MCP server", async () => { + await render(); + + const linkInput = Array.from( + container.querySelectorAll("input"), + ).find((input) => input.getAttribute("placeholder")?.startsWith("https://")); + await act(async () => setInputValue(linkInput!, "https://mcp.example.com/actions")); + await flushReact(); + await act(async () => { + buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("Which humans can use this credential?"); + expect(container.textContent).toContain("Which agents can use this connection?"); + expect(radioContaining("Just me")).toBeTruthy(); + expect(radioContaining("Any human in the company")?.getAttribute("aria-checked")).toBe("true"); + expect(radioContaining("Just agents I pick")).toBeTruthy(); + expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true"); + expect(container.textContent).not.toContain("Does it need a key?"); }); // ------------------------------------------------------------------------- @@ -511,7 +555,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).toContain("Your OAuth app"); expect(container.textContent).toContain("Open Asana app settings"); - expect(container.textContent).toContain("Create an Asana MCP OAuth app"); + expect(container.textContent).not.toContain("Create an Asana MCP OAuth app"); expect(container.textContent).toContain("Paperclip callback URL"); expect(container.textContent).toContain( "http://localhost:3000/api/tools/oauth/callback", @@ -614,6 +658,46 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); }); + it("prompts for one-time Cloud registration inside Gmail when managed methods are not yet advertised", async () => { + mockParams.appKey = "gmail"; + listGalleryMock.mockResolvedValue({ + apps: [{ + ...GMAIL, + methods: GMAIL.methods.filter((method) => !method.oauthStrategy), + ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, + }], + }); + getCloudConnectorEnrollmentMock.mockResolvedValueOnce({ + configured: false, + status: "not_configured", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: null, + environment: "staging", + origins: [], + }); + + await render(); + await passAccessStep(); + + expect(container.textContent).toContain("Connect with Paperclip"); + expect(container.textContent).not.toContain("Required once for managed Google sign-in."); + expect(container.textContent).not.toContain("Your OAuth app"); + + await act(async () => { + buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith( + "company-1", + "Paperclip", + "/apps/connect?source=gmail&stage=setup", + ); + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", + ); + }); + it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => { mockParams.appKey = "google-drive"; listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] }); @@ -624,14 +708,44 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers"); expect(container.textContent).not.toContain("final project-registration email"); expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment"); + expect(radioContaining("Just me")).toBeTruthy(); + expect(radioContaining("Any human in the company")?.getAttribute("aria-checked")).toBe("true"); await passAccessStep(); expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false"); - expect(container.textContent).toContain("Before connecting, enroll the signed-in Workspace account"); + expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account"); + expect(container.textContent).toContain("Review requirements"); expect(container.textContent).toContain("Your OAuth app"); }); + it("renders Google Calendar as one minimal, unboxed setup screen", async () => { + mockParams.appKey = "google-calendar"; + listGalleryMock.mockResolvedValue({ apps: [GOOGLE_CALENDAR] }); + + await render(); + await passAccessStep(); + + const duplicateHeadings = Array.from(container.querySelectorAll("h1, h2")).filter( + (heading) => heading.textContent?.trim() === "Connect Google Calendar", + ); + expect(duplicateHeadings).toHaveLength(1); + expect(container.textContent).toContain("What should Paperclip be able to do?"); + expect(container.textContent).toContain("Review requirements"); + expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events."); + expect(container.textContent).not.toContain("All event mutations require approval."); + expect(container.textContent).not.toContain("Google Workspace MCP servers are in Developer Preview."); + expect(container.textContent).not.toContain("We filled this in from the app."); + expect(container.textContent).not.toContain("You’ll continue to Google Calendar"); + expect(container.textContent).not.toContain("You’ll sign in before anything turns on."); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); + + const capabilityQuestion = Array.from(container.querySelectorAll("label")).find( + (label) => label.textContent === "What should Paperclip be able to do?", + ); + expect(capabilityQuestion?.closest(".max-w-xl")?.classList.contains("bg-card")).toBe(false); + }); + it("keeps Shopify prerequisite copy off access before collecting the store domain", async () => { mockParams.appKey = "shopify"; listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] }); @@ -661,6 +775,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB], capabilities: { + canCreateOrganizationGrant: false, + organizationGrantReason: "Only connection managers can share this credential.", canSetCompanyInstall: false, companyInstallReason: "Your company policy limits this choice to connection managers.", }, @@ -684,6 +800,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(anyAgent?.getAttribute("aria-label")).toContain( "Your company policy limits this choice to connection managers.", ); + const organization = radioContaining("Any human in the company"); + expect(organization?.disabled).toBe(true); + expect(organization?.getAttribute("title")).toBe( + "Only connection managers can share this credential.", + ); + expect(radioContaining("Just me")?.disabled).toBe(false); // "Just agents I pick" is the live alternative, so the step is not a dead end. const pick = Array.from( document.body.querySelectorAll('[role="radio"]'), @@ -1735,7 +1857,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(input.credentialValues).toBeUndefined(); }); - it("choosing Yes reveals one masked key field plus the lock reassurance", async () => { + it("choosing Yes reveals one masked key field without extra reassurance copy", async () => { await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); @@ -1755,7 +1877,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { container.querySelectorAll("input"), ).filter((i) => i.type === "password"); expect(passwordInputs).toHaveLength(1); - expect(container.textContent).toContain("Stored securely."); + expect(container.textContent).not.toContain("Stored securely."); await act(async () => setInputValue(passwordInputs[0], "secret-key")); await flushReact(); @@ -1789,10 +1911,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); + expect(container.textContent).toContain("Which humans can use this credential?"); + expect(container.textContent).toContain("Which agents can use this connection?"); + await passAccessStep(); + expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).not.toContain(zapierUrl); expect(container.textContent).toContain("token=REDACTED"); - expect(nameInputFrom(container)?.value).toBe("Zapier"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); expect(container.querySelector('input[type="password"]')).toBeNull(); await act(async () => { @@ -1810,7 +1936,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined(); }); - it("keeps Zapier visible and finishes without a separate access or install step", async () => { + it("gives Zapier the shared credential and agent access opener", async () => { mockSearch.value = "source=zapier"; listGalleryMock.mockResolvedValueOnce({ apps: [ @@ -1845,14 +1971,22 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await render(); - // The pasted-URL path enters its server address in this step, so an identity - // question cannot precede it; it keeps today's every-agent default rather - // than asking (PAP-17835 leaves the Access step to the curated app path). - expect(container.textContent).toContain("Step 1 of 1"); + expect(container.textContent).toContain("Step 1 of 2"); expect(container.textContent).toContain("Connect Zapier"); - expect(container.textContent).toContain("Add MCP URL"); + expect(container.textContent).toContain("Which humans can use this credential?"); + expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy(); expect(container.textContent).not.toContain("Pick the app you want your agents to use."); + expect(container.querySelector('input[placeholder^="https://mcp.zapier.com"]')).toBeNull(); + + await act(async () => { + radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await passAccessStep(); + + expect(container.textContent).toContain("Step 2 of 2"); + expect(container.textContent).toContain("Add MCP URL"); const linkInput = container.querySelector( 'input[placeholder^="https://mcp.zapier.com"]', @@ -1871,13 +2005,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, - name: "Zapier for the company", + name: "Zapier", galleryKey: "zapier", connectionMethodKey: "generated-url", + grantKind: "user", }); - // No grantKind is sent: the pasted-URL path never offered the choice, and - // sending "user" without asking would mis-scope the credential. - expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind"); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: ["action-1", "action-2"], @@ -1922,12 +2054,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { "link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77"; await render(); + expect(container.textContent).toContain("Which humans can use this credential?"); + await passAccessStep(); + expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).toContain("https://www.example.com/actions"); - const nameInput = Array.from(container.querySelectorAll("input")).find( - (i) => i.getAttribute("placeholder") === "My app", - ); - expect(nameInput?.value).toBe("Bla"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -1998,15 +2130,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock).not.toHaveBeenCalled(); }); - // PAP-11283: the gallery step exposes a Name field (default = app name) so a - // connection can be named at create time, matching the link flow. - function nameInputFrom(root: HTMLDivElement): HTMLInputElement | undefined { - return Array.from(root.querySelectorAll("input")).find( - (i) => i.getAttribute("placeholder") === "My app", - ); - } - - it("gallery key step defaults the Name field to the app name", async () => { + it("gallery setup names the connection automatically", async () => { await render(); await act(async () => { @@ -2016,7 +2140,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await passAccessStep(); expect(container.textContent).toContain("Connect GitHub"); - expect(nameInputFrom(container)?.value).toBe("GitHub"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github&stage=setup"); }); @@ -2108,7 +2232,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await render(); await passAccessStep(); - expect(nameInputFrom(container)?.value).toBe("Engineering GitHub for the company"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "replacement-key")); await flushReact(); @@ -2127,7 +2251,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); }); - it("a custom name in the gallery step is sent to the connect mutation", async () => { + it("uses the gallery app name automatically", async () => { await render(); await act(async () => { @@ -2136,7 +2260,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); await passAccessStep(); - await act(async () => setInputValue(nameInputFrom(container)!, "GitHub (stdio smoke)")); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "secret-key")); await flushReact(); @@ -2149,11 +2272,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "github", - name: "GitHub (stdio smoke) for the company", + name: "GitHub for the company", }); }); - it("a custom name on the Google Sheets step is sent to the connect mutation", async () => { + it("uses the Google Sheets app name automatically", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, @@ -2171,9 +2294,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); - // Default is the app name. - expect(nameInputFrom(container)?.value).toBe("Google Sheets"); - await act(async () => setInputValue(nameInputFrom(container)!, "Google Sheets (stdio smoke)")); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const textarea = container.querySelector("textarea"); await act(async () => setTextareaValue(textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit"), @@ -2188,7 +2309,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "google-sheets", - name: "Google Sheets (stdio smoke) for the company", + name: "Google Sheets for the company", configValues: { allowedSpreadsheetIds: ["sheet_123"] }, }); }); @@ -2308,12 +2429,18 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { return new ApiError(message, status, { error: message, details: { code } }); } - it("defaults the connection name from host, port, and path", async () => { + it("defaults the hidden connection name from host, port, and path", async () => { await render(); await gotoLinkFrame(container, "http://127.0.0.1:47399/mcp"); - expect(container.querySelector("#generic-mcp-name")?.value) - .toBe("127.0.0.1:47399/mcp"); + expect(container.querySelector("#generic-mcp-name")).toBeNull(); + await act(async () => { + buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ + name: "127.0.0.1:47399/mcp for the company", + }); }); it("keeps the endpoint host visible while skipping action review", async () => { @@ -2436,7 +2563,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { expect(container.textContent).not.toContain("PAPERCLIP_PUBLIC_URL"); }); - it("renders a name conflict as name guidance and focuses the Name field", async () => { + it("does not ask the operator to resolve an internal name conflict", async () => { connectAppMock.mockRejectedValue(apiError( 409, "tool_access_name_conflict", @@ -2450,9 +2577,9 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { await flushReact(); await flushReact(); - const nameInput = container.querySelector("#generic-mcp-name"); - expect(container.textContent).toContain("That name is taken"); - expect(document.activeElement).toBe(nameInput); + expect(container.textContent).toContain("Paperclip couldn’t name this connection"); + expect(container.textContent).not.toContain("Choose a different name"); + expect(container.querySelector("#generic-mcp-name")).toBeNull(); }); it("opens advanced authentication when the server wants a credential we can't discover", async () => { diff --git a/ui/src/pages/apps/AppsReview.tsx b/ui/src/pages/apps/AppsReview.tsx index 48b09298ef..6728eda7e7 100644 --- a/ui/src/pages/apps/AppsReview.tsx +++ b/ui/src/pages/apps/AppsReview.tsx @@ -14,17 +14,16 @@ import { ReviewQueueCard } from "./ReviewQueueCard"; * "Needs attention"; decisions live here. */ export function AppsReview() { - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Review" }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); if (!selectedCompanyId) { return
Select an organization to review approvals.
; diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index bfa71d6773..b4f5ee4778 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -13,6 +13,7 @@ const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const archiveConnectionMock = vi.hoisted(() => vi.fn()); const pushToastMock = vi.hoisted(() => vi.fn()); const navigateMock = vi.hoisted(() => vi.fn()); +const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/tools", () => ({ toolsApi: { @@ -45,7 +46,7 @@ vi.mock("@/context/CompanyContext", () => ({ })); vi.mock("@/context/BreadcrumbContext", () => ({ - useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), + useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }), })); vi.mock("@/context/ToastContext", () => ({ @@ -173,6 +174,10 @@ describe("Connectors landing page", () => { it("renders one connector list with the requested header and no gallery sections", async () => { await renderBrowse(); + expect(setBreadcrumbsMock).toHaveBeenCalledWith([]); + expect(setBreadcrumbsMock).not.toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ href: "/dashboard" }), + ])); expect(container.querySelector("header")?.textContent).toBe("Connectors"); expect( container.querySelector('header input[aria-label="Search connectors"]'), diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 5578b5ad98..7210fbbdfa 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -203,18 +203,18 @@ export function Browse() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [query, setQuery] = useState(""); const [connectionToRemove, setConnectionToRemove] = useState(null); useEffect(() => { - setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Connectors" }, - ]); + // Apps is already its own navigation root and the page has its own title. + // Leave the global bar empty instead of duplicating "Connectors" or + // offering a path back out of Apps. + setBreadcrumbs([]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx index 3cc355276c..69c584adbb 100644 --- a/ui/src/pages/apps/Connections.tsx +++ b/ui/src/pages/apps/Connections.tsx @@ -126,12 +126,11 @@ export function Connections() { useEffect(() => { setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Connections" }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), diff --git a/ui/src/pages/apps/gateways/GatewayDetail.tsx b/ui/src/pages/apps/gateways/GatewayDetail.tsx index d6e7e58e94..74eab6c3b8 100644 --- a/ui/src/pages/apps/gateways/GatewayDetail.tsx +++ b/ui/src/pages/apps/gateways/GatewayDetail.tsx @@ -31,7 +31,7 @@ export function GatewayDetail() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [snippetOpen, setSnippetOpen] = useState(false); const [editing, setEditing] = useState(false); @@ -103,13 +103,12 @@ export function GatewayDetail() { useEffect(() => { if (!gateway) return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Gateways", href: "/apps/gateways" }, { label: gateway.name }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name, gateway]); + }, [setBreadcrumbs, gateway]); const toggleMutation = useMutation({ mutationFn: () => diff --git a/ui/src/pages/apps/gateways/GatewaysList.tsx b/ui/src/pages/apps/gateways/GatewaysList.tsx index d0dcc8330c..44ef05dc97 100644 --- a/ui/src/pages/apps/gateways/GatewaysList.tsx +++ b/ui/src/pages/apps/gateways/GatewaysList.tsx @@ -32,19 +32,18 @@ export function GatewaysList() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [search, setSearch] = useState(""); const [creating, setCreating] = useState(false); useEffect(() => { setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Gateways" }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); const gatewaysQuery = useQuery({ queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"), diff --git a/ui/src/pages/apps/generic-mcp-connect.test.ts b/ui/src/pages/apps/generic-mcp-connect.test.ts index f13192eae8..e32f8776ca 100644 --- a/ui/src/pages/apps/generic-mcp-connect.test.ts +++ b/ui/src/pages/apps/generic-mcp-connect.test.ts @@ -90,9 +90,10 @@ describe("genericConnectGuidance", () => { expect(genericConnectGuidance("oauth_redirect_origin_unsupported", null).focus).toBe("deployment"); }); - it("points at the name field when the connection name is already used", () => { + it("does not ask the operator to resolve an internal name conflict", () => { const guidance = genericConnectGuidance("tool_access_name_conflict", null); - expect(guidance).toMatchObject({ title: "That name is taken", focus: "name" }); + expect(guidance).toMatchObject({ title: "Paperclip couldn’t name this connection", focus: "none" }); + expect(guidance.body).not.toContain("different name"); }); it("passes a rejected header's own message through", () => { diff --git a/ui/src/pages/apps/generic-mcp-connect.ts b/ui/src/pages/apps/generic-mcp-connect.ts index 98ea9dcaec..d98eca617a 100644 --- a/ui/src/pages/apps/generic-mcp-connect.ts +++ b/ui/src/pages/apps/generic-mcp-connect.ts @@ -78,7 +78,7 @@ export function defaultGenericMcpName(url: string): string | null { * this to decide whether to reopen the URL field or the credential section, rather * than leaving them to guess which of the two was wrong. */ -export type GenericConnectFocus = "url" | "name" | "credentials" | "deployment" | "none"; +export type GenericConnectFocus = "url" | "credentials" | "deployment" | "none"; export interface GenericConnectGuidance { title: string; @@ -126,9 +126,9 @@ export function genericConnectGuidance( }; case "tool_access_name_conflict": return { - title: "That name is taken", - body: "Choose a different name for this connection, then try again.", - focus: "name", + title: "Paperclip couldn’t name this connection", + body: "Try connecting again.", + focus: "none", }; case "oauth_challenge": return { diff --git a/ui/storybook/stories/permitted-vs-installed.stories.tsx b/ui/storybook/stories/permitted-vs-installed.stories.tsx index 01bd259481..b56d76cc97 100644 --- a/ui/storybook/stories/permitted-vs-installed.stories.tsx +++ b/ui/storybook/stories/permitted-vs-installed.stories.tsx @@ -269,13 +269,21 @@ function SeededAccessStep({ initialGrantKind, initialChoice, initialAgentIds, - capabilities = { canSetCompanyInstall: true, editableAgentIds: AGENT_IDS }, + capabilities = { + canCreateOrganizationGrant: true, + canSetCompanyInstall: true, + editableAgentIds: AGENT_IDS, + }, }: { authKind: "oauth" | "api_key" | "none"; initialGrantKind: "user" | "organization"; initialChoice: "specific" | "all"; initialAgentIds: Set; - capabilities?: { canSetCompanyInstall: boolean; editableAgentIds: string[] }; + capabilities?: { + canCreateOrganizationGrant: boolean; + canSetCompanyInstall: boolean; + editableAgentIds: string[]; + }; }) { const client = useMemo(() => { const c = new QueryClient({