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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-02 14:05:53 -05:00 committed by GitHub
parent 5716fe907e
commit 8c3b8c432a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1183 additions and 668 deletions

View File

@ -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-*`

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -82,12 +82,12 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
selfHostedDefault: true,
},
enableApps: {
title: "Apps",
title: "Apps (compatibility)",
description:
"Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.",
"Deprecated compatibility key. Apps is always enabled; stored and managed values are ignored.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
cloudDefault: true,
selfHostedDefault: true,
},
enablePipelines: {
title: "Pipelines",

View File

@ -54,6 +54,7 @@ export interface InstanceExperimentalSettings {
enableManagedSandboxOnly: boolean;
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
/** @deprecated Compatibility key only. Apps is always enabled. */
enableApps: boolean;
enablePipelines: boolean;
enableCases: boolean;

View File

@ -263,6 +263,8 @@ export interface ToolConnectionCapabilities {
* authorization from membership roles or wait for a connection id.
*/
export interface ToolConnectionCreateCapabilities {
canCreateOrganizationGrant: boolean;
organizationGrantReason: string | null;
canSetCompanyInstall: boolean;
companyInstallReason: string | null;
}

View File

@ -96,10 +96,10 @@ describe("instance experimental settings validators", () => {
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", () => {

View File

@ -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),

View File

@ -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 () => {

View File

@ -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<string, unknown>;
// 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 () => {

View File

@ -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", () => {

View File

@ -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,

View File

@ -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;
}

View File

@ -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", () => {

View File

@ -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<typeof heartbeatService>;
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
@ -180,8 +183,31 @@ export function connectionIntentOAuthOutcomeHtml(input: {
return `<!doctype html><html><head><meta charset="utf-8"><title>Connection authorization</title></head><body><p>Returning to Paperclip…</p><script>const message=${message};const targetOrigin=${targetOrigin}||window.location.origin;if(window.opener&&window.opener!==window){window.opener.postMessage(message,targetOrigin);window.close();}else{window.location.replace(${fallback});}</script></body></html>`;
}
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<ToolConnectionCreateCapabilities> {
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);

View File

@ -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 };
}

View File

@ -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<PaperclipCloudConnectorEnrollmentStatus> {
@ -143,6 +145,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: {
label?: string;
companyId?: string;
initiatedBy?: string;
returnTo?: string;
env?: NodeJS.ProcessEnv;
request?: typeof fetch;
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
@ -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);

View File

@ -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 };

View File

@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
const res = await request.post("/api/companies", { data: { name: `Apps navigation ${label} ${Date.now()}` } });
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",
@ -123,6 +121,7 @@ test.describe.serial("not-connected app page", () => {
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 });

View File

@ -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()}` },
});

View File

@ -18,8 +18,6 @@ async function discoverCompany(request: APIRequestContext): Promise<SeedResult>
});
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",

View File

@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
const res = await request.post("/api/companies", { data: { name: `Apps navigation ${label} ${Date.now()}` } });
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",

View File

@ -21,8 +21,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
const res = await request.post("/api/companies", { data: { name: `prosumer MCP flow ${label} ${Date.now()}` } });
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",
@ -163,6 +161,11 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
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 });

View File

@ -22,11 +22,6 @@ async function newCompany(request: APIRequestContext): Promise<Seed> {
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

View File

@ -24,7 +24,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
const body = await json<{ id: string; issuePrefix?: string; prefix?: string; urlKey?: string }>(
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" };
}

View File

@ -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) {

View File

@ -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() {
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
<Route path="tools" element={<LegacyToolsRedirect />} />
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
<Route element={<AppsExperimentalGate />}>
<Route path="apps" element={<Browse />} />
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
<Route
path="apps/vercel-connect"
element={<AppsConnectEntryRoute credentialSource="vercel_connect" />}
/>
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
<Route path="apps/review" element={<AppsReview />} />
{/* Connector health is inline on the Apps landing page; keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
<Route path="apps/advanced/gateways" element={<GatewaysList />} />
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />
<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
</Route>
<Route path="apps" element={<Browse />} />
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
<Route
path="apps/vercel-connect"
element={<AppsConnectEntryRoute credentialSource="vercel_connect" />}
/>
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
<Route path="apps/review" element={<AppsReview />} />
{/* Connector health is inline on the Apps landing page; keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
<Route path="apps/advanced/gateways" element={<GatewaysList />} />
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />
<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
<Route path="company/settings/instance" element={<Navigate to="/company/settings" replace />} />
<Route element={<HiddenSettingsPageGate pageKey="instance.profile" />}>
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />

View File

@ -289,8 +289,8 @@ export type ToolPolicyTestResponse = {
export const toolsApi = {
getCloudConnectorEnrollment: () =>
api.get<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment"),
startCloudConnectorEnrollment: (companyId: string, label?: string) =>
api.post<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment", { companyId, label }),
startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) =>
api.post<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment", { companyId, label, returnTo }),
// --- Applications ---
listGallery: (companyId: string) =>
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),

View File

@ -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 }) => (
<div data-testid="navigate" data-to={to} data-replace={String(replace)} />
),
Outlet: () => <div data-testid="apps-content">Apps content</div>,
}));
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(
<QueryClientProvider client={queryClient}>
<AppsExperimentalGate />
</QueryClientProvider>,
);
});
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();
});
});

View File

@ -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 <Navigate to="/dashboard" replace />;
return <Outlet />;
}

View File

@ -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();

View File

@ -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 ? (
<CompanySettingsSidebar />
) : appsEnabled && appDetailConnectionId ? (
) : appDetailConnectionId ? (
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
) : appsEnabled && appDetailApplicationId ? (
) : appDetailApplicationId ? (
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
) : appsEnabled && (isAppsRoute || isToolsRoute) ? (
) : isAppsRoute || isToolsRoute ? (
<AppsSidebar />
) : routeSidebarSlot ? (
<PluginSlotMount

View File

@ -479,24 +479,22 @@ describe("Sidebar", () => {
});
});
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();
});
});

View File

@ -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"
/>
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
</SidebarSection>
{/* Classic mode restores the per-project collapsible below Work. */}
@ -275,7 +275,6 @@ export function Sidebar() {
<SidebarSection label="Organization" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}

View File

@ -7,6 +7,7 @@ import {
Check,
ChevronDown,
ChevronRight,
Cloud,
Copy,
Link2,
Loader2,
@ -209,10 +210,10 @@ const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
key: 2,
};
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, 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<Step>(requestedAppKey || prefill.link || zapierSource ? "key" : "gallery");
const [step, setStep] = useState<Step>(
requestedAppKey ? "key" : prefill.link || zapierSource ? "access" : "gallery",
);
const [entry, setEntry] = useState<AppDefinition | null>(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<string | null>(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 couldnt 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 (
<OAuthConnectStateScreen
@ -1625,16 +1697,54 @@ export function ConnectionSetupFlow({
setInstallAgentIds(new Set());
setInstallChoice("all");
setGrantKind(reconnectGrantKind ?? "organization");
setStep("key");
setStep("access");
}}
/>
)}
{step === "key" && entry && (
{step === "key" && entry && showConnectorEnrollmentStep ? (
<div className="mx-auto max-w-xl">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
<Cloud className="h-5 w-5" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground">
Connect with Paperclip
</h2>
</div>
</div>
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
<InlineBanner tone="danger" className="mt-4">
{connectorEnrollmentError ?? "Paperclip couldnt check Cloud registration. Try again."}
</InlineBanner>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
Back
</Button>
<Button
type="button"
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
onClick={() => {
setConnectorEnrollmentError(null);
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) openConnectorEnrollment(verificationUrl);
else startConnectorEnrollment.mutate();
}}
>
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{connectorEnrollmentQuery.data?.status === "pending"
? "Continue"
: "Connect with Paperclip"}
</Button>
</div>
</div>
) : step === "key" && entry ? (
<KeyStep
entry={entry}
name={galleryName}
onNameChange={setGalleryName}
values={credentials}
onChange={setCredentials}
oauthClientId={curatedOAuthClientId}
@ -1708,13 +1818,12 @@ export function ConnectionSetupFlow({
connectApp();
}}
/>
)}
) : null}
{step === "key" && !entry && linkUrl && !zapierSource && (
<LinkConnectStep
link={linkUrl}
name={linkName}
onNameChange={setLinkName}
needsKey={linkNeedsKey}
onNeedsKeyChange={(next) => {
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}
<div>
<h1 className="text-2xl font-bold tracking-tight">
{appIdentity ? `Connect ${appIdentity.name}` : "Connect an app"}
{appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
@ -1943,7 +2052,7 @@ export function OAuthConnectStateScreen({
unverifiedHost={unverifiedHost}
onCancel={onCancel}
/>
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="mx-auto max-w-xl">
<div className="flex items-start gap-3">
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
{phase === "error" ? (
@ -1975,10 +2084,6 @@ export function OAuthConnectStateScreen({
)}
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
</div>
<p className="mt-5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="h-3.5 w-3.5" />
Your authorization stays in Paperclips encrypted secret store.
</p>
</div>
</div>
);
@ -2002,20 +2107,8 @@ function ZapierConnectStep({
const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com");
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-start gap-3">
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
<Link2 className="h-5 w-5 text-muted-foreground" />
</span>
<div className="min-w-0">
<h2 className="text-xl font-bold tracking-tight">Connect Zapier</h2>
<p className="mt-1 text-sm text-muted-foreground">
Paste the complete MCP URL Zapier gives you, including its token.
</p>
</div>
</div>
<div className="mt-8">
<div className="mx-auto max-w-xl">
<div>
<label className="text-sm font-medium text-foreground">Zapier MCP URL</label>
<Input
type="password"
@ -2030,15 +2123,12 @@ function ZapierConnectStep({
className="mt-2 h-11"
autoFocus
/>
<p className="mt-2 text-xs text-muted-foreground">
The token is part of the URL. Paperclip stores it securely and checks the connection before enabling actions.
</p>
{link.trim() && !isZapierLink && (
<p className="mt-2 text-xs text-destructive">Paste a valid Zapier URL to continue.</p>
)}
</div>
<div className="mt-8 flex items-center justify-between">
<div className="mt-6 flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
</Button>
@ -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<HTMLInputElement>(null);
const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link;
useEffect(() => {
if (guidance?.focus === "name") nameInputRef.current?.focus();
}, [guidance]);
const updateHeader = (id: string, patch: Partial<CustomHeaderRow>) => {
onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row)));
};
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-start gap-3">
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
<Link2 className="h-5 w-5 text-muted-foreground" />
</span>
<div className="min-w-0">
<h2 className="text-xl font-bold tracking-tight">Connect your own MCP server</h2>
<p className="mt-1 truncate font-mono text-sm text-muted-foreground" title={displayedLink}>{displayedLink}</p>
<UnverifiedServerBadge host={host} className="mt-2" />
</div>
<div className="mx-auto max-w-xl">
<div className="flex items-center justify-between gap-3">
<p className="min-w-0 truncate font-mono text-sm text-muted-foreground" title={displayedLink}>{displayedLink}</p>
<UnverifiedServerBadge host={host} />
</div>
{matchedEntry && onUseMatchedEntry ? (
@ -2432,22 +2509,7 @@ function LinkConnectStep({
</div>
) : null}
<div className="mt-8 space-y-6">
<div>
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-name">Name</label>
<Input
ref={nameInputRef}
id="generic-mcp-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="My app"
className="mt-2 h-11"
/>
<p className="mt-2 text-xs text-muted-foreground">
We filled this in from the address. Change it if you'd like.
</p>
</div>
<div className="mt-6 space-y-6">
{showSimpleKeyQuestion && (
<div>
<label className="mr-2 text-sm font-medium text-foreground">Does it need a key?</label>
@ -2485,7 +2547,6 @@ function LinkConnectStep({
className="mt-2 h-11 font-mono"
/>
</div>
<StoredSecurelyNote />
</div>
) : null}
@ -2553,7 +2614,6 @@ function LinkConnectStep({
Add another header
</Button>
{headerError ? <p className="text-xs text-destructive">{headerError}</p> : null}
<StoredSecurelyNote />
</div>
) : null}
@ -2590,7 +2650,6 @@ function LinkConnectStep({
className="mt-2 h-11 font-mono"
/>
</div>
<StoredSecurelyNote />
</div>
) : null}
</CollapsibleContent>
@ -2601,15 +2660,10 @@ function LinkConnectStep({
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
</Button>
<div className="flex items-center gap-3">
<span className="hidden text-xs text-muted-foreground sm:inline">
We'll check the server before turning anything on.
</span>
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitting ? "Checking…" : "Check link"}
</Button>
</div>
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitting ? "Checking…" : "Check link"}
</Button>
</div>
</div>
);
@ -2649,21 +2703,6 @@ const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string
},
];
function StoredSecurelyNote() {
return (
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium text-foreground">Stored securely.</div>
<div className="text-xs text-muted-foreground">
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.
</div>
</div>
</div>
);
}
function SegmentedOption({
label,
selected,
@ -2690,44 +2729,8 @@ function SegmentedOption({
);
}
function ConnectionNameField({
name,
onNameChange,
}: {
name: string;
onNameChange: (next: string) => void;
}) {
return (
<div>
<label className="text-sm font-medium text-foreground">Name</label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="My app"
className="mt-2 h-11"
/>
<p className="mt-2 text-xs text-muted-foreground">
We filled this in from the app. Change it to tell connections apart.
</p>
</div>
);
}
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<string, string>;
onChange: (next: Record<string, string>) => 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 && <p className="mt-2 text-xs text-muted-foreground">Choose a connection method to continue.</p>}
@ -2919,18 +2918,9 @@ function KeyStep({
const parsed = parseGoogleSheetIds(googleSheetsLinks);
const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-center gap-3">
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} darkLogoUrl={entry.branding.darkLogoUrl} size={48} />
<div>
<h2 className="text-lg font-bold tracking-tight sm:text-xl">Connect Google Sheets</h2>
<p className="text-sm text-muted-foreground">{copy.short}</p>
</div>
</div>
<div className="mt-8 space-y-6">
<div className="mx-auto max-w-xl">
<div className="space-y-6">
{capabilitySelection}
<ConnectionNameField name={name} onNameChange={onNameChange} />
{robotEmail ? (
<div>
@ -2992,43 +2982,28 @@ function KeyStep({
);
}
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-center gap-3">
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} darkLogoUrl={entry.branding.darkLogoUrl} size={48} />
<div>
<h2 className="text-xl font-bold tracking-tight">Connect {entry.name}</h2>
<p className="text-sm text-muted-foreground">{copy.short}</p>
</div>
</div>
const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl;
<div className="mt-8 space-y-6">
return (
<div className="mx-auto max-w-xl">
{requirementsUrl ? (
<div className="mb-4 flex justify-end">
<a
href={requirementsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Review requirements
<ArrowUpRight className="h-3 w-3" />
</a>
</div>
) : null}
<div className="space-y-6">
{capabilitySelection}
{authenticationSelection}
{method?.warnings?.map((warning) => (
<InlineBanner key={warning} tone="warning" compact>
{warning}
</InlineBanner>
))}
{method ? (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<p className="text-sm text-foreground">{method.guidanceMd}</p>
{entry.docsUrl || method.consoleLinks?.docs ? (
<a
href={method.consoleLinks?.docs ?? entry.docsUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
>
Review {entry.name} setup requirements
<ArrowUpRight className="h-3 w-3" />
</a>
) : null}
</div>
) : null}
{usingVercel && vercelReview && vercelConnectAvailability ? (
<div className="space-y-4 rounded-lg border border-border p-4">
<div>
@ -3065,8 +3040,6 @@ function KeyStep({
</div>
) : null}
<ConnectionNameField name={name} onNameChange={onNameChange} />
{standardConfigFields.map((field) => (
<MethodConfigField
key={field.key}
@ -3122,13 +3095,7 @@ function KeyStep({
/>
) : null}
{usingVercel ? null : method && fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
{method.auth === "oauth"
? `Youll continue to ${entry.name} to sign in securely.`
: "This app doesnt need a key. Just connect to continue."}
</p>
) : (
{usingVercel || !method || fields.length === 0 ? null : (
fields.map((field) => (
<div key={field.configPath}>
<label className="text-sm font-medium text-foreground">
@ -3157,38 +3124,20 @@ function KeyStep({
))
)}
{!usingVercel && method?.auth === "api_key" && (
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium text-foreground">Your key is stored securely.</div>
<div className="text-xs text-muted-foreground">
You can replace it anytime from this apps page.
</div>
</div>
</div>
)}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
</Button>
<div className="flex items-center gap-3">
<span className="hidden text-xs text-muted-foreground sm:inline">
{method?.auth === "oauth"
? "Youll sign in before anything turns on."
: "Well check the key before turning anything on."}
</span>
<Button onClick={onConnect} disabled={submitting || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitting
? "Checking…"
: usingVercel
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
</Button>
</div>
<Button onClick={onConnect} disabled={submitting || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitting
? "Checking…"
: usingVercel
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
</Button>
</div>
</div>
);
@ -3286,7 +3235,6 @@ function OAuthClientFields({
className="mt-2 h-11 font-mono"
/>
</div>
<StoredSecurelyNote />
</div>
);
}
@ -3380,7 +3328,8 @@ export function AccessStep({
setInstallAgentIds: (ids: Set<string>) => void;
/** Task-hosted intents grant reach only to the agent that requested it. */
lockedAgentId?: string;
capabilities?: Pick<ToolConnectionCreateCapabilities, "canSetCompanyInstall"> & {
capabilities?: Pick<ToolConnectionCreateCapabilities, "canCreateOrganizationGrant" | "canSetCompanyInstall"> & {
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: <UsersRound className="h-4 w-4" aria-hidden="true" />,
accessibleLabel: canCreateOrganizationGrant
? "Any human in the company"
: `Any human in the company. Unavailable: ${capabilities?.organizationGrantReason ??
"Only a connection manager can share this credential with the organization."}`,
tooltip: canCreateOrganizationGrant
? undefined
: capabilities?.organizationGrantReason ??
"Only a connection manager can share this credential with the organization.",
disabled: !canCreateOrganizationGrant,
},
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
/>

View File

@ -1,15 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
export function useAppsEnabled() {
const query = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
return {
enabled: query.data?.enableApps === true,
loaded: query.isFetched,
};
}

View File

@ -58,7 +58,6 @@ const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
'button[aria-label="Toggle built-in agents experimental setting"]';
const BETA_SKILLS_TOGGLE_SELECTOR =
'button[aria-label="Toggle beta skills experimental setting"]';
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
const SUMMARIES_TOGGLE_SELECTOR =
'button[aria-label="Toggle summaries experimental setting"]';
const STATUS_CARDS_TOGGLE_SELECTOR =
@ -73,7 +72,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
enableApps: false,
enableApps: true,
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
@ -190,17 +189,11 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(warning?.textContent).toContain("no compatibility guarantees");
});
it("enables the Apps UI from experimental settings", async () => {
it("does not render an Apps experimental setting", async () => {
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(() => toggle?.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true");
expect(container.querySelector('button[aria-label="Toggle apps experimental setting"]')).toBeNull();
expect(container.textContent).not.toContain("Show the Apps navigation");
});
it("does not render the Conference Room Chat experimental setting for now", async () => {
@ -664,19 +657,19 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => {
it("renders a managed key locked with the badge while unmanaged keys stay editable", async () => {
await renderPage({
...defaultExperimentalSettings(),
enableApps: true,
enableBuiltInAgents: true,
managedKeys: {
enableApps: { managed: true, managedBy: "paperclip-cloud" },
enableBuiltInAgents: { managed: true, managedBy: "paperclip-cloud" },
},
});
expect(container.textContent).toContain(MANAGED_BADGE_TEXT);
const appsToggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
expect(appsToggle?.getAttribute("aria-checked")).toBe("true");
expect(appsToggle?.disabled).toBe(true);
const builtInAgentsToggle = container.querySelector<HTMLButtonElement>(BUILT_IN_AGENTS_TOGGLE_SELECTOR);
expect(builtInAgentsToggle?.getAttribute("aria-checked")).toBe("true");
expect(builtInAgentsToggle?.disabled).toBe(true);
await act(() => appsToggle?.click());
await act(() => builtInAgentsToggle?.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
@ -729,12 +722,12 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => {
expect(container.textContent).not.toContain(MANAGED_BADGE_TEXT);
const appsToggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
expect(appsToggle?.disabled).toBe(false);
const builtInAgentsToggle = container.querySelector<HTMLButtonElement>(BUILT_IN_AGENTS_TOGGLE_SELECTOR);
expect(builtInAgentsToggle?.disabled).toBe(false);
await act(() => appsToggle?.click());
await act(() => builtInAgentsToggle?.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableBuiltInAgents: true });
});
});
@ -863,7 +856,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => {
expect(container.textContent).not.toContain("Enable Environments");
expect(container.textContent).toContain("Beta skills");
expect(container.textContent).toContain("Apps");
expect(container.textContent).not.toContain("Show the Apps navigation");
});
it("shows every toggle when nothing is hidden", async () => {

View File

@ -203,7 +203,6 @@ export function InstanceExperimentalSettings() {
const enableNativeRunner = experimentalQuery.data?.enableNativeRunner === true;
const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableApps = experimentalQuery.data?.enableApps === true;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
@ -275,17 +274,6 @@ export function InstanceExperimentalSettings() {
</p>
</div>
<ExperimentalToggleCard
title="Apps"
description="Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling."
checked={enableApps}
onCheckedChange={(checked) => toggleMutation.mutate({ enableApps: checked })}
disabled={toggleMutation.isPending}
settingKey="enableApps"
managed={managedKeys.enableApps}
ariaLabel="Toggle apps experimental setting"
/>
<ExperimentalToggleCard
title="Beta skills"
description="Allow agents to pin beta releases of the Paperclip core skill. Disabling this returns every agent to the default live skill without removing saved pins."

View File

@ -74,7 +74,7 @@ export function AppDetail() {
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompany, selectedCompanyId } = useCompany();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
@ -250,13 +250,12 @@ export function AppDetail() {
useEffect(() => {
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(

View File

@ -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" }),

View File

@ -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<HTMLInputElement>("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<HTMLInputElement>("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("Youll continue to Google Calendar");
expect(container.textContent).not.toContain("Youll 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<HTMLButtonElement>('[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<HTMLInputElement>("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<HTMLInputElement>(
'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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLTextAreaElement>("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<HTMLInputElement>("#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<HTMLInputElement>("#generic-mcp-name");
expect(container.textContent).toContain("That name is taken");
expect(document.activeElement).toBe(nameInput);
expect(container.textContent).toContain("Paperclip couldnt 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 () => {

View File

@ -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 <div className="p-6 text-sm text-muted-foreground">Select an organization to review approvals.</div>;

View File

@ -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"]'),

View File

@ -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<ConnectionRemovalTarget | null>(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__"),

View File

@ -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__"),

View File

@ -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: () =>

View File

@ -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__"),

View File

@ -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 couldnt name this connection", focus: "none" });
expect(guidance.body).not.toContain("different name");
});
it("passes a rejected header's own message through", () => {

View File

@ -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 couldnt name this connection",
body: "Try connecting again.",
focus: "none",
};
case "oauth_challenge":
return {

View File

@ -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<string>;
capabilities?: { canSetCompanyInstall: boolean; editableAgentIds: string[] };
capabilities?: {
canCreateOrganizationGrant: boolean;
canSetCompanyInstall: boolean;
editableAgentIds: string[];
};
}) {
const client = useMemo(() => {
const c = new QueryClient({