diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 13896ddd..fe44b0d9 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -1503,7 +1503,22 @@ export async function handleOidcRefresh( return res; } catch (err) { - console.error("[nimblebrain] Token refresh failed:", err); + // invalid_grant = refresh token expired/revoked (e.g. session ended due to + // inactivity). Expected; the client re-authenticates. Log terse, no stack. + // Anything else is unexpected — log the message (still no bundled-source dump). + const expired = + typeof err === "object" && + err !== null && + "error" in err && + (err as { error?: unknown }).error === "invalid_grant"; + if (expired) { + console.warn( + "[nimblebrain] Token refresh rejected (session expired) — client will re-authenticate", + ); + } else { + const reason = err instanceof Error ? err.message : String(err); + console.error("[nimblebrain] Token refresh failed:", reason); + } return apiError(401, "refresh_failed", "Token refresh failed"); } } diff --git a/src/connectors/catalog.yaml b/src/connectors/catalog.yaml index 6d3d1b7d..de12ace6 100644 --- a/src/connectors/catalog.yaml +++ b/src/connectors/catalog.yaml @@ -4,8 +4,8 @@ # before it leaves the registry. # # `_meta["ai.nimblebrain/connector"]` carries platform-specific fields -# that don't fit upstream-defined slots (defaultBinding, OAuth flow type, -# operator-setup pointer, recommended scopes, search tags, UI hints). +# that don't fit upstream-defined slots (OAuth flow type, operator-setup +# pointer, recommended scopes, search tags, UI hints). # # Operators with custom curation needs override this list at runtime by # pointing `NB_REGISTRIES` at a JSON config that adds their own @@ -32,7 +32,6 @@ servers: url: https://mcp.asana.com/v2/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: static operatorSetup: portalUrl: https://app.asana.com/0/my-apps @@ -52,7 +51,6 @@ servers: url: https://mcp.notion.com/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [docs, knowledge] @@ -68,7 +66,6 @@ servers: url: https://mcp.linear.app/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [issues, project-mgmt] @@ -90,7 +87,6 @@ servers: url: https://bindings.mcp.cloudflare.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [infra, devops] @@ -112,7 +108,6 @@ servers: url: https://mcp.dropbox.com/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: static requiredScopes: - files.metadata.read @@ -151,7 +146,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: gmail @@ -206,7 +200,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: outlook @@ -244,7 +237,6 @@ servers: url: https://mcp.mercury.com/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [banking, finance] @@ -260,7 +252,6 @@ servers: url: https://mcp.neon.tech/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [database, postgres] @@ -276,7 +267,6 @@ servers: url: https://mcp.paypal.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [payments] @@ -292,7 +282,6 @@ servers: url: https://mcp.sentry.dev/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [observability, errors] @@ -308,7 +297,6 @@ servers: url: https://mcp.stripe.com/ _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [payments, billing] @@ -335,7 +323,6 @@ servers: url: https://mcp.webflow.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [design, cms] @@ -351,7 +338,6 @@ servers: url: https://mcp.wix.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [website, cms] @@ -367,7 +353,6 @@ servers: url: https://mcp.granola.ai/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [meetings, notes] @@ -391,7 +376,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: box @@ -426,7 +410,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: github @@ -461,7 +444,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: googlecalendar @@ -495,7 +477,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: quickbooks @@ -531,7 +512,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: linkedin @@ -566,7 +546,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: slack @@ -601,7 +580,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: whatsapp @@ -635,7 +613,6 @@ servers: url: https://gmailmcp.googleapis.com/mcp/v1 _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static requiredScopes: - https://www.googleapis.com/auth/gmail.readonly @@ -661,7 +638,6 @@ servers: url: https://mcp.hubspot.com _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static operatorSetup: portalUrl: https://developers.hubspot.com/docs/apps/developer-platform/build-apps/integrate-with-the-remote-hubspot-mcp-server @@ -681,7 +657,6 @@ servers: url: https://mcp.microsoft.com/mail _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static requiredScopes: - https://graph.microsoft.com/Mail.ReadWrite @@ -705,7 +680,6 @@ servers: url: https://mcp.zoom.us/mcp _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static requiredScopes: [meeting:read, recording:read] operatorSetup: diff --git a/src/connectors/server-detail.ts b/src/connectors/server-detail.ts index b15a29d0..de694a0a 100644 --- a/src/connectors/server-detail.ts +++ b/src/connectors/server-detail.ts @@ -8,8 +8,7 @@ * `MpakSource` reads the same shape natively from mpak's `/v1/servers/...` * via the SDK. Consumers always see one type. The `_meta` extension * `ai.nimblebrain/connector` carries our platform-specific fields - * (defaultBinding, auth, operatorSetup, etc.) without polluting - * upstream-defined slots. + * (auth, operatorSetup, etc.) without polluting upstream-defined slots. * * Validated at every system boundary so an invalid entry is dropped * at the source it came from, never reaching the UI / agent. Each @@ -106,21 +105,6 @@ export interface Package { * it undefined). */ export interface NimbleBrainConnectorMeta { - /** - * Default install target — a UX hint the platform uses to decide - * which workspace receives the install action when the catalog entry - * is exercised. - * - * - `"workspace"` — install into the active workspace; bundle is - * shared with all members. Granted to admins only. - * - `"personal"` — install into the caller's personal workspace - * (`personalWorkspaceIdFor(userId)`); tokens are sole-owner by - * construction. - * - * Not a per-ref `oauthScope` — every installed ref is workspace-bound - * post-Stage-2. This field selects the wsId. - */ - defaultBinding?: "workspace" | "personal"; /** * OAuth flow type for remote services. * diff --git a/src/registries/projection.ts b/src/registries/projection.ts index 65ea63fc..1a2477fe 100644 --- a/src/registries/projection.ts +++ b/src/registries/projection.ts @@ -34,7 +34,6 @@ export interface ProjectionContext { * - `description` ← `ServerDetail.description` * - `iconUrl` ← `ServerDetail.icons[0].src` (theme-aware picker is a follow-up) * - `tags` ← `_meta.ai.nimblebrain/connector.tags` - * - `defaultBinding` ← `_meta.ai.nimblebrain/connector.defaultBinding` ?? `"workspace"` * - `install` ← derived from `packages[]` (mpak-bundle) or `remotes[]` (remote-oauth) * * Returns null if the entry isn't installable (no packages, no remotes, @@ -59,7 +58,6 @@ export function projectServerDetailToDirectoryEntry( description: s.description, ...(iconUrl ? { iconUrl } : {}), ...(meta?.tags && meta.tags.length > 0 ? { tags: meta.tags } : {}), - defaultBinding: meta?.defaultBinding ?? "workspace", install, }; } @@ -112,7 +110,6 @@ export interface ConnectorCatalogEntry { /** Remote MCP server URL — the value that goes into the bundle `url`. */ url: string; auth: "dcr" | "static" | "composio"; - defaultBinding: "workspace" | "personal"; requiredScopes?: string[]; additionalAuthorizationParams?: Record; operatorSetup?: { portalUrl: string; hint: string; clientSecretKey: string }; @@ -149,7 +146,6 @@ export function serverDetailToCatalogEntry(s: ServerDetail): ConnectorCatalogEnt iconUrl, url: remote.url, auth: meta?.auth ?? "dcr", - defaultBinding: meta?.defaultBinding ?? "workspace", ...(meta?.requiredScopes ? { requiredScopes: meta.requiredScopes } : {}), ...(meta?.additionalAuthorizationParams ? { additionalAuthorizationParams: meta.additionalAuthorizationParams } diff --git a/src/registries/types.ts b/src/registries/types.ts index fe8f691b..a08574a2 100644 --- a/src/registries/types.ts +++ b/src/registries/types.ts @@ -91,12 +91,6 @@ export interface DirectoryEntry { description: string; iconUrl?: string; tags?: string[]; - /** - * Default install target. UI uses this to filter Personal vs Workspace - * browse. `"workspace"` installs into the active workspace; `"personal"` - * installs into the caller's personal workspace. - */ - defaultBinding: "personal" | "workspace"; install: InstallAction; /** * For static-auth entries: whether the workspace has operator OAuth diff --git a/src/tools/connector-tools.ts b/src/tools/connector-tools.ts index 33f0ade2..7b66b503 100644 --- a/src/tools/connector-tools.ts +++ b/src/tools/connector-tools.ts @@ -28,13 +28,11 @@ import type { InProcessTool } from "./in-process-app.ts"; * shell, and keeping one tool minimizes route bloat. * * Stage 2: every install is workspace-scoped. The `install` action - * REQUIRES an explicit `wsId` argument — the UI picks the target - * workspace and passes it in; the tool hard-errors on missing `wsId`, - * mirroring Stage 1's `startBundleSource` precedent. No - * default-to-personal fallback inside the tool. `defaultBinding` on a - * catalog entry is a UX hint the picker consults to preselect a - * workspace; the tool itself never reads it to pick a target. The - * bundle ref's `oauthScope` is always `"workspace"`. + * targets the request's active workspace (`ctx.getWorkspaceId()`, set + * from the `/w/` route); an explicit `wsId` arg overrides it for + * direct API callers. Any workspace — personal or shared — is a valid + * target; the tool never special-cases the target's `isPersonal` flag. + * The bundle ref's `oauthScope` is always `"workspace"`. * * Persistence: `WorkspaceStore.bundles[]` + * `workspaces//credentials/...` for tokens. @@ -946,12 +944,14 @@ function parseDirectoryEntry(input: unknown): DirectoryEntry | null { /** * Remote OAuth install — targets the explicit `wsId` passed in by the - * dispatcher. Composio-auth entries additionally require a non-personal - * (shared) target workspace because their credential layout under - * `credentials/composio//` only fits the shared-workspace - * shape today. Static-auth entries require operator OAuth client config - * persisted under `workspace.json#oauthOperatorApps[entry.id]` + the - * matching client_secret in the credential store before this can proceed. + * dispatcher (the request's active workspace). Every workspace — personal + * or shared — is a valid target: install, boot-state derivation, and + * disconnect cleanup are all keyed purely on `wsId`, so the credential + * layout under `credentials/composio//` works identically + * regardless of the target's `isPersonal` flag. Static-auth entries + * require operator OAuth client config persisted under + * `workspace.json#oauthOperatorApps[entry.id]` + the matching + * client_secret in the credential store before this can proceed. */ async function handleInstallRemoteOAuth( ctx: ManageConnectorsContext, @@ -973,22 +973,6 @@ async function handleInstallRemoteOAuth( if (!action.composio) { return errResult(`"${entry.name}" is composio-auth but missing composio config block.`); } - // Composio's state lives at workspace scope only: - // `credentials/composio//connection.json` is under - // `workspaces//`, and `lifecycle.disconnect`'s composio - // cleanup branch gates on `isWorkspaceScope`. A personal-workspace - // target would silently bypass that cleanup and orphan the upstream - // Composio account on disconnect. Reject at install time rather - // than ship the latent footgun. The source of truth is the target - // workspace record's `isPersonal` flag (NOT the catalog's - // `defaultBinding` — that's a UX hint, not an authority). - if (ws.isPersonal === true) { - return errResult( - `"${entry.name}" is composio-auth and cannot install into a personal workspace — ` + - "the credential layout does not yet support personal-workspace bindings. " + - "Pick a shared workspace from the install dialog instead.", - ); - } const { authConfigEnv } = action.composio; if (!process.env.COMPOSIO_API_KEY?.trim()) { return errResult( @@ -1117,10 +1101,9 @@ async function handleInstallRemoteOAuth( // for vendors whose remote `type` is `sse` — PayPal / Cloudflare / // Webflow / Wix in the bundled catalog today. transport: composioWiring?.transport ?? { type: action.transportType }, - // Post-Stage-2: every ref's oauthScope is "workspace". The catalog's - // `defaultBinding` selects which workspace the install targets - // (personal vs active), NOT a per-ref scope literal — see the - // dispatch logic above. + // Post-Stage-2: every ref's oauthScope is "workspace". The install + // targets the request's active workspace (see the dispatch logic + // above); the ref carries no per-target scope literal. oauthScope: "workspace", ...(action.requiredScopes ? { scopes: action.requiredScopes } : {}), ...(action.additionalAuthorizationParams @@ -1273,10 +1256,12 @@ async function handleInstallRemoteOAuth( return { content: textContent( startWarning - ? `Installed "${entry.name}" in the target workspace. Source eager-start failed (${startWarning}) — click Connect to retry.` + ? `Installed "${entry.name}" in ${ + isPersonalTarget ? "your personal workspace" : "this workspace" + }. Source eager-start failed (${startWarning}) — click Connect to retry.` : isPersonalTarget ? `Installed "${entry.name}" in your personal workspace.` - : `Installed "${entry.name}" for this workspace.`, + : `Installed "${entry.name}" in this workspace.`, ), structuredContent: { ok: true, diff --git a/src/tools/system-tools.ts b/src/tools/system-tools.ts index 0a1da2b9..531792a4 100644 --- a/src/tools/system-tools.ts +++ b/src/tools/system-tools.ts @@ -203,10 +203,9 @@ export async function createSystemTools( systemToolDefs.push(createManageWorkspacesTool(mergedCtx)); } - // Connectors tool. Single surface for both workspace-targeted and - // personal-workspace-targeted connectors — the install destination - // is chosen by the catalog entry's `defaultBinding`, and disconnects - // look up the binding workspace from the installed ref. + // Connectors tool. Single surface for all connectors — the install + // destination is the request's active workspace (personal or shared), + // and disconnects look up the binding workspace from the installed ref. if (runtime && manageWorkspacesCtx) { systemToolDefs.push( createManageConnectorsTool({ diff --git a/test/integration/connector-tools-install.test.ts b/test/integration/connector-tools-install.test.ts index d61de747..59509206 100644 --- a/test/integration/connector-tools-install.test.ts +++ b/test/integration/connector-tools-install.test.ts @@ -150,7 +150,6 @@ function dcrEntry(): DirectoryEntry { registryType: "static", name: "Granola", description: "Meeting notes", - defaultBinding: "personal", install: { kind: "remote-oauth", url: "https://api.granola.test/mcp", diff --git a/test/unit/composio-auth.test.ts b/test/unit/composio-auth.test.ts index e758d999..1bbeed87 100644 --- a/test/unit/composio-auth.test.ts +++ b/test/unit/composio-auth.test.ts @@ -145,7 +145,6 @@ function composioEntry(id: string) { iconUrl: "https://example.com/icon.png", url: "https://backend.composio.dev/v3/mcp/SERVER", auth: "composio" as const, - defaultBinding: "workspace" as const, composio: { toolkit: "gmail", authConfigEnv: "COMPOSIO_GMAIL_AUTH_CONFIG_ID", @@ -706,7 +705,6 @@ describe("POST /v1/composio-auth/initiate", () => { iconUrl: "https://example.com/icon.png", url: "https://mcp.example.com/mcp", auth: "dcr" as const, - defaultBinding: "workspace" as const, }; const { app } = makeApp(entry as unknown as ReturnType); const res = await app.request("http://nb.test/v1/composio-auth/initiate", { diff --git a/test/unit/connector-directory.test.ts b/test/unit/connector-directory.test.ts index 77ca98de..98a7ec63 100644 --- a/test/unit/connector-directory.test.ts +++ b/test/unit/connector-directory.test.ts @@ -313,7 +313,6 @@ describe("ConnectorDirectory.list", () => { _meta: { "ai.nimblebrain/connector": { auth: "static", - defaultBinding: "workspace", operatorSetup: { portalUrl: "https://app.asana.com/0/developer-console", hint: "Create OAuth app", @@ -329,7 +328,7 @@ describe("ConnectorDirectory.list", () => { icons: [{ src: "https://x.test/granola.svg" }], remotes: [{ type: "streamable-http", url: "https://api.granola.test/mcp" }], _meta: { - "ai.nimblebrain/connector": { auth: "dcr", defaultBinding: "workspace" }, + "ai.nimblebrain/connector": { auth: "dcr" }, }, }, ]); @@ -449,7 +448,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", mpakServer({ _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "dcr", docsUrl: "javascript:alert(1)", }, @@ -475,7 +473,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", mpakServer({ _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "static", operatorSetup: { portalUrl: "javascript:fetch('https://evil')", @@ -505,7 +502,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", mpakServer({ _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "dcr", additionalAuthorizationParams: { client_id: "attacker-controlled" }, }, @@ -553,7 +549,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", icons: [{ src: "https://x.test/safe.png" }], _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "dcr", docsUrl: "https://safe.example/docs", }, diff --git a/test/unit/connector-tools-composio-install.test.ts b/test/unit/connector-tools-composio-install.test.ts index 90721070..e44ccd5c 100644 --- a/test/unit/connector-tools-composio-install.test.ts +++ b/test/unit/connector-tools-composio-install.test.ts @@ -86,8 +86,12 @@ import { type ManageConnectorsContext, } from "../../src/tools/connector-tools.ts"; import { ToolRegistry } from "../../src/tools/registry.ts"; -import { WorkspaceStore } from "../../src/workspace/workspace-store.ts"; +import { personalWorkspaceIdFor, WorkspaceStore } from "../../src/workspace/workspace-store.ts"; import { _resetComposioConfigForTest } from "../../src/composio/sdk.ts"; +import { + hasPersistedComposioConnection, + saveComposioConnection, +} from "../../src/bundles/composio-connection.ts"; // ── Catalog fixture ───────────────────────────────────────────────── // @@ -105,7 +109,6 @@ function gmailEntry(): import("../../src/registries/types.ts").DirectoryEntry { registryType: "static", name: "Gmail", description: "Read, send, and draft mail", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: GMAIL_URL, @@ -483,4 +486,93 @@ describe("manage_connectors.install (composio-auth)", () => { const ws = await h.workspaceStore.get(h.wsId); expect(ws?.bundles ?? []).toHaveLength(0); }); + + // ── personal-workspace target ───────────────────────────────────── + // + // The path this whole change unblocks: composio-auth install into a + // personal workspace. A stale guard used to reject it inside the + // `auth === "composio"` branch; dcr/static were never gated, so a + // dcr fixture would NOT cover this. These two tests pin the composio + // path specifically — install succeeds, and disconnect cleanup is + // keyed on the personal `wsId` with no `isPersonal` special-casing + // (the invariant the removed guard's safety argument rests on). + + test("(g) composio install into a personal workspace persists the ref (the path the removed guard blocked)", async () => { + process.env.COMPOSIO_API_KEY = "k_test"; + process.env.COMPOSIO_GMAIL_AUTH_CONFIG_ID = "ac_gmail"; + + const personalWsId = personalWorkspaceIdFor(ADMIN.id); + await h.workspaceStore.create("Admin Personal", `user_${ADMIN.id}`, { + isPersonal: true, + ownerUserId: ADMIN.id, + }); + + const tool = buildTool(h); + const result = await tool.handler({ + action: "install", + entry: gmailEntry(), + wsId: personalWsId, + }); + + // Eager startBundleSource fails on the fake session URL (same as + // the shared-workspace path), so this returns success-with-warning, + // NOT the old "cannot install into a personal workspace" error. + expect(result.isError).toBe(false); + const sc = result.structuredContent as { scope?: string; wsId?: string }; + expect(sc.scope).toBe("workspace"); + expect(sc.wsId).toBe(personalWsId); + + // The ref landed in the personal workspace with the composio marker + // and the post-T008 workspace scope. + const personalWs = await h.workspaceStore.get(personalWsId); + const installed = personalWs?.bundles.find( + (b): b is Extract => "url" in b && "composio" in b, + ); + expect(installed).toBeDefined(); + expect(installed?.composio?.connectorId).toBe(GMAIL_ID); + expect(installed?.oauthScope).toBe("workspace"); + }); + + test("(h) disconnect of a personal-workspace composio bundle runs cleanup keyed on that wsId (no isPersonal gate)", async () => { + process.env.COMPOSIO_API_KEY = "k_test"; + process.env.COMPOSIO_GMAIL_AUTH_CONFIG_ID = "ac_gmail"; + + const personalWsId = personalWorkspaceIdFor(ADMIN.id); + await h.workspaceStore.create("Admin Personal", `user_${ADMIN.id}`, { + isPersonal: true, + ownerUserId: ADMIN.id, + }); + + // Install seeds the BundleRef + lifecycle instance for the personal + // workspace (the eager-start failure is caught and logged after). + const tool = buildTool(h); + await tool.handler({ action: "install", entry: gmailEntry(), wsId: personalWsId }); + + // Simulate a completed Composio OAuth: a connection.json under the + // personal workspace's credential path. Cleanup must find and remove + // THIS file — proving it resolves by wsId, personal included. + await saveComposioConnection(h.workDir, personalWsId, GMAIL_ID, { + connectedAccountId: "ca_personal", + toolkit: "gmail", + userId: ADMIN.id, + connectedAt: "2026-06-02T00:00:00.000Z", + status: "ACTIVE", + }); + expect(hasPersistedComposioConnection(h.workDir, personalWsId, GMAIL_ID)).toBe(true); + + // Drop the API key so cleanup skips the upstream revoke (offline); + // the local-delete branch is the part that pins the wsId routing. + delete process.env.COMPOSIO_API_KEY; + + // Disconnect through the real lifecycle caller (serverName is the + // slug of GMAIL_ID; principal is the workspace principal). If a + // future change gated this caller on `isPersonal`, the personal + // workspace's connection would survive and orphan upstream — this + // assertion fails first. + await h.runtime + .getLifecycle() + .disconnect("com-google-gmail", personalWsId, "_workspace", { workDir: h.workDir }); + + expect(hasPersistedComposioConnection(h.workDir, personalWsId, GMAIL_ID)).toBe(false); + }); }); diff --git a/test/unit/connector-tools.test.ts b/test/unit/connector-tools.test.ts index 9778084f..b40d5ccf 100644 --- a/test/unit/connector-tools.test.ts +++ b/test/unit/connector-tools.test.ts @@ -52,7 +52,7 @@ const NOTION_ID = "com.notion/mcp"; * Build a DirectoryEntry shaped like what `StaticSource` projects for * Asana — used by install tests since the install API takes the full * entry, not an id. Field set matches the real source output; tests - * can override pieces (operatorSetup, defaultBinding) per case. + * can override pieces (operatorSetup, install) per case. */ function asanaEntry(over: Partial = {}): DirectoryEntry { return { @@ -61,7 +61,6 @@ function asanaEntry(over: Partial = {}): DirectoryEntry { registryType: "static", name: "Asana", description: "Tasks, projects, and team workflows", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: ASANA_URL, @@ -94,7 +93,6 @@ function mpakEntry(over: { id?: string; pkg?: string; name?: string } = {}): Dir registryType: "mpak", name: over.name ?? "Echo", description: "Reference MCP server for testing", - defaultBinding: "workspace", install: { kind: "mpak-bundle", package: over.pkg ?? "@nimblebraininc/echo", @@ -797,11 +795,10 @@ describe("manage_connectors.install", () => { // Install defaults to the request's workspace (`getWorkspaceId()`), // but here the session workspace is explicitly null (third arg to // buildTool) and the call omits `wsId` — so there is no workspace - // anywhere. The tool must NOT recover a target from identity or the - // catalog's `defaultBinding`; with nothing in context it hard-errors, - // naming the missing arg. (This is the no-default-to-personal guard: - // it fires only when no workspace is in scope at all, not on the - // normal path where the route supplies one.) + // anywhere. The tool must NOT recover a target from identity; with + // nothing in context it hard-errors, naming the missing arg. (This is + // the no-default-to-personal guard: it fires only when no workspace is + // in scope at all, not on the normal path where the route supplies one.) const tool = buildTool(h, ADMIN_USER, null); const result = await tool.handler({ action: "install", entry: mpakEntry() }); expect(result.isError).toBe(true); @@ -872,7 +869,6 @@ describe("manage_connectors.install", () => { registryType: "static", name: "Evil", description: "x", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: "javascript:alert(1)", @@ -904,7 +900,6 @@ describe("manage_connectors.install", () => { registryType: "static", name: "Evil", description: "x", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: "https://mcp.evil.test/mcp", @@ -918,12 +913,11 @@ describe("manage_connectors.install", () => { expect(text.toLowerCase()).toContain("install action is required"); }); - test("personal-workspace install: wsId picked by the dialog targets personalWorkspaceIdFor(userId); stored ref has oauthScope=workspace", async () => { - // Stage 2 / T010: the install dialog's WorkspaceTargetPicker picks - // the personal workspace by `defaultBinding === "personal"` and - // supplies its id explicitly. The tool itself never reads the - // catalog's `defaultBinding` to pick a target — that is the picker - // UI's job. Pin three things: + test("personal-workspace install: an explicit personal wsId targets personalWorkspaceIdFor(userId); stored ref has oauthScope=workspace", async () => { + // Any workspace is a valid install target, personal included — the + // caller supplies the target wsId (from the active `/w/` route + // or an explicit arg) and the tool installs there with no special- + // casing of `isPersonal`. Pin three things: // - The recorded `wsId` IS `personalWorkspaceIdFor(callerId)` // (the canonical helper, NOT a hand-built `ws_user_` // template literal — `check:personal-workspace-id` is `src/`- @@ -949,7 +943,6 @@ describe("manage_connectors.install", () => { registryType: "static", name: "Canva", description: "x", - defaultBinding: "personal", install: { kind: "remote-oauth", url: "https://mcp.canva.com/mcp", diff --git a/test/unit/connectors-catalog.test.ts b/test/unit/connectors-catalog.test.ts index 4c151698..ade22f55 100644 --- a/test/unit/connectors-catalog.test.ts +++ b/test/unit/connectors-catalog.test.ts @@ -92,7 +92,7 @@ describe("validateServerDetail", () => { test("accepts _meta with arbitrary reverse-DNS extension keys", () => { const detail = makeValid({ _meta: { - "ai.nimblebrain/connector": { defaultBinding: "workspace", auth: "dcr" }, + "ai.nimblebrain/connector": { auth: "dcr" }, "dev.mpak/registry": { downloads: 42 }, }, }); diff --git a/test/unit/directory-entry-projection.test.ts b/test/unit/directory-entry-projection.test.ts index 7ada9df1..af1a0928 100644 --- a/test/unit/directory-entry-projection.test.ts +++ b/test/unit/directory-entry-projection.test.ts @@ -102,7 +102,6 @@ describe("projectServerDetailToDirectoryEntry", () => { remotes: [{ type: "streamable-http", url: "https://example.com/mcp" }], _meta: { "ai.nimblebrain/connector": { - defaultBinding: "personal", auth: "static", requiredScopes: ["read", "write"], additionalAuthorizationParams: { access_type: "offline" }, @@ -118,7 +117,6 @@ describe("projectServerDetailToDirectoryEntry", () => { CTX, ); expect(e?.install.kind).toBe("remote-oauth"); - expect(e?.defaultBinding).toBe("personal"); expect(e?.tags).toEqual(["email"]); if (e?.install.kind === "remote-oauth") { expect(e.install.auth).toBe("static"); @@ -158,15 +156,6 @@ describe("projectServerDetailToDirectoryEntry", () => { } }); - test("defaults defaultBinding to 'workspace' when no NimbleBrain meta is present", () => { - const e = projectServerDetailToDirectoryEntry( - detail({ - remotes: [{ type: "streamable-http", url: "https://example.com/mcp" }], - }), - CTX, - ); - expect(e?.defaultBinding).toBe("workspace"); - }); test("defaults remote auth to 'dcr' when meta is absent", () => { const e = projectServerDetailToDirectoryEntry( diff --git a/web/src/App.tsx b/web/src/App.tsx index 16728cd5..d1994898 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -387,14 +387,8 @@ function AuthenticatedAppContent({ } /> } /> } /> - } - /> - } - /> + } /> + } /> } /> diff --git a/web/src/__tests__/connector-sections.test.tsx b/web/src/__tests__/connector-sections.test.tsx index 28e41540..abaf91f7 100644 --- a/web/src/__tests__/connector-sections.test.tsx +++ b/web/src/__tests__/connector-sections.test.tsx @@ -180,7 +180,6 @@ function dcrConnector(over: Partial = {}): InstalledConnecto iconUrl: "", url: "https://api.granola.test/mcp", auth: "dcr", - defaultBinding: "workspace", }, ...over, }; @@ -207,7 +206,6 @@ function staticAuthConnector(over: Partial = {}): InstalledC iconUrl: "", url: "https://app.asana.com/api/mcp", auth: "static", - defaultBinding: "workspace", operatorSetup: { portalUrl: "https://app.asana.com/0/developer-console", hint: "Create OAuth app", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4afde944..6e25afbe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -654,7 +654,6 @@ export interface ConnectorCatalogEntry { iconUrl: string; url: string; auth: "dcr" | "static" | "composio"; - defaultBinding: "workspace" | "personal"; requiredScopes?: string[]; additionalAuthorizationParams?: Record; operatorSetup?: { portalUrl: string; hint: string; clientSecretKey: string }; @@ -945,7 +944,6 @@ export interface DirectoryEntry { description: string; iconUrl?: string; tags?: string[]; - defaultBinding: "personal" | "workspace"; /** * Static-auth entries: true when the workspace has both clientId and * client_secret configured. Undefined for entries where operator diff --git a/web/src/components/connectors/ConnectorList.tsx b/web/src/components/connectors/ConnectorList.tsx index ff76651a..d67d5c8b 100644 --- a/web/src/components/connectors/ConnectorList.tsx +++ b/web/src/components/connectors/ConnectorList.tsx @@ -7,28 +7,20 @@ import { EmptyState } from "../../pages/settings/components"; import { ConnectorIcon } from "./ConnectorIcon"; /** - * Lists installed connectors for a single page mode (Personal or - * Workspace). The list is intentionally minimal — icon, name, status - * + action verb when something needs attention, chevron link to - * Configure. Type / interactive / version metadata is deferred to - * the Configure page so the list stays scannable. + * Lists installed connectors for the active workspace. The list is + * intentionally minimal — icon, name, status + action verb when + * something needs attention, chevron link to Configure. Type / + * interactive / version metadata is deferred to the Configure page so + * the list stays scannable. * * Browsing for new connectors lives on a separate /browse page * reached via the action in the page header — the list itself only * shows what's already installed. * - * `mode` is a UI affordance (which settings page is rendering us); - * `getInstalledConnectors` always reads from the active workspace - * (Stage 2: personal connectors live in the user's personal - * workspace, addressed by setting it active before navigating here). + * `getInstalledConnectors` reads from the active workspace, whichever + * (personal or shared) the current `/w/:slug` route names. */ -export function ConnectorList({ - mode: _mode, - configureBasePath, -}: { - mode: "personal" | "workspace"; - configureBasePath: string; -}) { +export function ConnectorList({ configureBasePath }: { configureBasePath: string }) { const [installed, setInstalled] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/web/src/components/connectors/ConnectorStatusHero.tsx b/web/src/components/connectors/ConnectorStatusHero.tsx index acdbb229..a6a5aa4a 100644 --- a/web/src/components/connectors/ConnectorStatusHero.tsx +++ b/web/src/components/connectors/ConnectorStatusHero.tsx @@ -67,7 +67,6 @@ export function ConnectorStatusHero({ description: cat.description, ...(installed.iconUrl ? { iconUrl: installed.iconUrl } : {}), tags: cat.tags, - defaultBinding: cat.defaultBinding, install: { kind: "remote-oauth", url: cat.url, diff --git a/web/src/components/connectors/OperatorOAuthSection.tsx b/web/src/components/connectors/OperatorOAuthSection.tsx index 38bf7f6d..1927e557 100644 --- a/web/src/components/connectors/OperatorOAuthSection.tsx +++ b/web/src/components/connectors/OperatorOAuthSection.tsx @@ -41,7 +41,6 @@ export function OperatorOAuthSection({ description: cat.description, ...(installed.iconUrl ? { iconUrl: installed.iconUrl } : {}), tags: cat.tags, - defaultBinding: cat.defaultBinding, install: { kind: "remote-oauth", url: cat.url, diff --git a/web/src/components/connectors/ToolPermissionsTable.tsx b/web/src/components/connectors/ToolPermissionsTable.tsx index 09309262..d16af091 100644 --- a/web/src/components/connectors/ToolPermissionsTable.tsx +++ b/web/src/components/connectors/ToolPermissionsTable.tsx @@ -23,19 +23,7 @@ import { * a Configure page with 10+ tools. A heavy border made the page feel * walled off; lighter chrome lets it sit alongside the other sections. */ -export function ToolPermissionsTable({ - serverName, - mode: _mode, -}: { - serverName: string; - /** - * UI affordance — which settings page is rendering us. The REST - * helpers always read/write workspace-scope permissions (Stage 2: - * personal connectors live in the user's personal workspace, - * addressed by setting it active before navigating here). - */ - mode: "personal" | "workspace"; -}) { +export function ToolPermissionsTable({ serverName }: { serverName: string }) { const [tools, setTools] = useState([]); const [policies, setPolicies] = useState>({}); const [loading, setLoading] = useState(true); diff --git a/web/src/context/WorkspaceContext.tsx b/web/src/context/WorkspaceContext.tsx index ca027d2a..e3d67a65 100644 --- a/web/src/context/WorkspaceContext.tsx +++ b/web/src/context/WorkspaceContext.tsx @@ -16,12 +16,11 @@ export interface WorkspaceInfo { userRole?: "admin" | "member"; /** * `true` for the user's personal workspace (auto-provisioned at first - * login, sole-owner-by-design). Drives the install dialog's preselection - * heuristic — personal-typical connectors (`defaultBinding: "personal"`) - * default to the personal workspace; non-personal workspaces require an - * explicit pick. The platform's bootstrap endpoint sets this; the - * `parseWorkspaceListResponse` fallback also propagates it so the - * shell mounted via either path agrees. + * login, sole-owner-by-design). Connectors install into whichever + * workspace is active, personal or shared; this flag only marks the + * sole-owner workspace for display. The platform's bootstrap endpoint + * sets this; the `parseWorkspaceListResponse` fallback also propagates + * it so the shell mounted via either path agrees. */ isPersonal?: boolean; } diff --git a/web/src/lib/bootstrap.ts b/web/src/lib/bootstrap.ts index 47dc8fda..dbfbed94 100644 --- a/web/src/lib/bootstrap.ts +++ b/web/src/lib/bootstrap.ts @@ -70,10 +70,10 @@ export function parseWorkspaceListResponse(raw: unknown): WorkspaceInfo[] { : [], ...(userRole ? { userRole } : {}), // Pass through `isPersonal` from either contract. Bootstrap - // returns it directly; `manage_workspaces.list` is expected to - // surface it now that T010's install dialog depends on it for - // preselection. Missing field gracefully degrades to "not - // identified as personal" — the picker shows no preselection. + // returns it directly; `manage_workspaces.list` surfaces it too. + // It no longer gates connector installs (every workspace is a + // valid target) — it only marks the sole-owner workspace for + // display. Missing field degrades to "not identified as personal." ...(typeof ws.isPersonal === "boolean" ? { isPersonal: ws.isPersonal } : {}), }; }); diff --git a/web/src/pages/settings/ConnectorBrowsePage.tsx b/web/src/pages/settings/ConnectorBrowsePage.tsx index 4386769e..7fb71d6c 100644 --- a/web/src/pages/settings/ConnectorBrowsePage.tsx +++ b/web/src/pages/settings/ConnectorBrowsePage.tsx @@ -22,7 +22,7 @@ import { roleAtLeast, useScopedRole } from "../../hooks/useScopedRole"; * because the catalog is long enough that a single column wastes * horizontal space. */ -export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" }) { +export function ConnectorBrowsePage() { const [entries, setEntries] = useState([]); const [errors, setErrors] = useState>([]); const [installed, setInstalled] = useState([]); @@ -37,8 +37,8 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" } const navigate = useNavigate(); const { slug } = useParams<{ slug: string }>(); - // Workspace connectors are addressed by the URL slug. (Personal-scope - // connectors have no UI surface today; the mode is kept for the API.) + // Connectors are addressed by the URL slug — install targets whichever + // workspace (personal or shared) the user is currently viewing. const backPath = `/w/${slug}/settings/connectors`; const configureBasePath = backPath; @@ -92,16 +92,14 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" } return false; } - // Filter to mode, drop installed, apply search. The UI `mode` is a - // page-view discriminator (`"personal"` vs `"workspace"`) — both - // values map 1:1 onto the catalog entry's `defaultBinding`. The - // legacy `"user"` literal was renamed in T009 (Group D audit) because - // a route/page mode indicator is not an oauthScope. + // One unified browse list: every connector is installable into any + // workspace, so the only filtering is dropping already-installed + // entries and applying the search query. const visibleEntries = useMemo(() => { - const inScope = entries.filter((e) => e.defaultBinding === mode && !isInstalled(e)); - if (!query.trim()) return inScope; + const available = entries.filter((e) => !isInstalled(e)); + if (!query.trim()) return available; const q = query.trim().toLowerCase(); - return inScope.filter( + return available.filter( (e) => e.name.toLowerCase().includes(q) || e.description.toLowerCase().includes(q) || @@ -109,7 +107,7 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" } ); // installedByKey is captured by isInstalled via closure; re-running // when it changes is what lets newly-installed connectors disappear. - }, [entries, mode, query, installedByKey]); + }, [entries, query, installedByKey]); // Install into the workspace the user is already in. The page is // mounted under `/w//...`, so the route names an unambiguous @@ -161,9 +159,7 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" }

Browse connectors

- {mode === "personal" - ? "Personal services to connect to your account." - : "Tools and services to add to this workspace."} + Tools and services to add to this workspace.

diff --git a/web/src/pages/settings/ConnectorDetailPage.tsx b/web/src/pages/settings/ConnectorDetailPage.tsx index 6bb21c94..a5b007fe 100644 --- a/web/src/pages/settings/ConnectorDetailPage.tsx +++ b/web/src/pages/settings/ConnectorDetailPage.tsx @@ -33,14 +33,13 @@ import { roleAtLeast, useScopedRole } from "../../hooks/useScopedRole"; * for any ready connector — that's what users come here for once * setup is past. * - * Reachable from `/settings/{personal,workspace}/connectors/:serverName`; - * `mode` comes from the route prefix. + * Reachable from `/w/:slug/settings/connectors/:serverName`. */ -export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" }) { +export function ConnectorDetailPage() { const { serverName = "", slug } = useParams<{ serverName: string; slug: string }>(); const navigate = useNavigate(); - // Workspace connectors are addressed by the URL slug. (Personal-scope - // connectors have no UI surface today; the mode is kept for the API.) + // Connectors are addressed by the URL slug — the page acts on whichever + // workspace (personal or shared) the slug names. const backPath = `/w/${slug}/settings/connectors`; const [installed, setInstalled] = useState(null); @@ -55,10 +54,10 @@ export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" } const [uninstallArmed, setUninstallArmed] = useState(false); const role = useScopedRole(); - // Workspace-mode edit gates ride on ws_admin. Personal-mode is - // always editable by the owner — it's their personal workspace, and - // the workspace store enforces sole-owner membership invariants. - const canManage = mode === "personal" ? true : roleAtLeast(role, "ws_admin"); + // Edit gates ride on ws_admin. In a personal workspace the sole owner + // is its admin (the workspace store enforces that invariant), so the + // same check covers both cases. + const canManage = roleAtLeast(role, "ws_admin"); const refresh = useCallback(async () => { setError(null); @@ -66,8 +65,7 @@ export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" } // Targeted single-connector fetch — avoids building entries // (and tools() round-trips) for every other installed bundle // when we only render one. Server resolves the connector from - // serverName; the page `mode` is a UI affordance, not a server - // arg. + // serverName. const res = await getInstalledConnector(serverName); setInstalled(res.installed); } catch (err) { @@ -193,7 +191,7 @@ export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" }
- +
{configureModalOpen && ( diff --git a/web/src/pages/settings/WorkspaceConnectorsTab.tsx b/web/src/pages/settings/WorkspaceConnectorsTab.tsx index 71b6d34d..0781ee2e 100644 --- a/web/src/pages/settings/WorkspaceConnectorsTab.tsx +++ b/web/src/pages/settings/WorkspaceConnectorsTab.tsx @@ -25,7 +25,7 @@ export function WorkspaceConnectorsTab() { } /> - + );