From b51112798f1794776adf837982be58d231d5ac3d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:08:32 -0500 Subject: [PATCH] feat(apps): improve gateway and workspace connection UX (#12340) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - App connections must work in both the operator UI and agent tool gateway. > - The first stack layer adds secure remote connections. > - Operators still need clear setup, test, and recovery states. > - This pull request adds the gateway behavior and the workspace connection experience. > - The benefit is a connection flow that is easier to understand and recover. ## Linked Issues or Issue Description Refs #11965 This is stack 2 of 11. It depends on stack 1 and replaces another reviewable part of #11965. ## What Changed - Improve remote tool gateway connection behavior. - Add clearer app setup, test, and recovery states. - Add focused server and UI tests for the new paths. - Keep the diff isolated from later identity and catalog work. - Stabilize DNS-pinned remote HTTP protocol fixtures and the managed-runtime public-origin fixture for this independently tested layer. ## Verification - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` (150 passed) - `pnpm test:run` - `pnpm check:token-gates` - `pnpm build` ## Risks - Gateway errors now surface through new user-facing states. - A stale connection can require a new setup attempt. - The change does not add a database migration. - The injected HTTP transport and public URL are test-only fixtures; production DNS pinning and runtime behavior are unchanged. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, 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 - [x] My branch name describes the change 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 --- packages/shared/src/app-definitions.test.ts | 2 + .../shared/src/types/workspace-runtime.ts | 1 + .../shared/src/validators/tool-access.test.ts | 11 + packages/shared/src/validators/tool-access.ts | 2 +- .../shared/src/workspace-commands.test.ts | 50 + packages/shared/src/workspace-commands.ts | 26 +- scripts/smoke/posthog-live.mjs | 426 +++++- server/src/__tests__/better-auth.test.ts | 29 + .../execution-workspaces-service.test.ts | 18 +- .../__tests__/generic-mcp-connection.test.ts | 30 +- .../heartbeat-runtime-mcp-servers.test.ts | 82 +- .../src/__tests__/invite-join-grants.test.ts | 8 + .../__tests__/managed-loopback-auth.test.ts | 101 ++ .../managed-runtime-public-url.test.ts | 45 + .../remote-http-endpoint-guard.test.ts | 39 + .../__tests__/remote-http-rebinding.test.ts | 38 +- .../server-startup-feedback-export.test.ts | 16 +- .../src/__tests__/tool-access-service.test.ts | 337 ++++- .../__tests__/tool-gateway-service.test.ts | 4 +- server/src/__tests__/tool-gateway.test.ts | 128 +- .../src/__tests__/workspace-runtime.test.ts | 192 ++- server/src/app.ts | 1 + server/src/auth/better-auth.ts | 100 +- server/src/config.ts | 20 +- server/src/index.ts | 5 +- server/src/routes/execution-workspaces.ts | 1 + server/src/routes/projects.ts | 1 + server/src/routes/tool-access.ts | 181 ++- server/src/routes/tool-gateway.ts | 336 +++-- server/src/services/authorization.ts | 15 + server/src/services/company-member-roles.ts | 8 + server/src/services/execution-workspaces.ts | 31 +- server/src/services/heartbeat.ts | 89 +- .../services/remote-http-endpoint-guard.ts | 26 +- server/src/services/remote-http-fetch.ts | 20 +- server/src/services/tool-access.ts | 394 +++--- .../src/services/tool-connection-activity.ts | 229 ++++ server/src/services/tool-gateway.ts | 21 +- .../workspace-runtime-exposure.test.ts | 1 + .../workspace-runtime-read-model.test.ts | 59 + .../services/workspace-runtime-read-model.ts | 18 +- server/src/services/workspace-runtime.ts | 178 ++- .../e2e/application-delete-screenshot.spec.ts | 2 +- tests/e2e/applications-crud.spec.ts | 12 +- tests/e2e/apps-dark-mode-shots.spec.ts | 4 +- tests/e2e/apps-prosumer-mcp-flow.spec.ts | 4 +- ui/src/App.test.tsx | 7 + ui/src/App.tsx | 4 + ui/src/api/tools.test.ts | 34 + ui/src/api/tools.ts | 27 +- .../components/AppConnectionSidebar.test.tsx | 14 +- ui/src/components/AppsSidebar.test.tsx | 14 +- ui/src/components/AppsSidebar.tsx | 19 +- ui/src/lib/queryKeys.ts | 3 +- ui/src/pages/AgentToolsTab.tsx | 8 +- ui/src/pages/apps/AppDetail.test.tsx | 113 +- ui/src/pages/apps/AppDetail.tsx | 116 +- ui/src/pages/apps/AppNotConnected.test.tsx | 43 +- ui/src/pages/apps/AppNotConnected.tsx | 114 +- ui/src/pages/apps/AppsConnect.test.tsx | 24 +- ui/src/pages/apps/AppsConnect.tsx | 173 +-- ui/src/pages/apps/Browse.test.tsx | 130 +- ui/src/pages/apps/Browse.tsx | 197 ++- ui/src/pages/apps/Connections.test.tsx | 71 +- ui/src/pages/apps/Connections.tsx | 136 +- .../apps/agent-selector-contract.test.ts | 27 + .../apps/app-detail/PermissionsPanel.tsx | 35 +- .../pages/apps/app-detail/TestPanel.test.tsx | 10 + ui/src/pages/apps/app-detail/TestPanel.tsx | 4 + ui/src/pages/apps/app-tabs.ts | 3 +- ui/src/pages/apps/connection-owner.tsx | 57 + ui/src/pages/apps/gateways/AppsSubNav.tsx | 40 - .../gateways/ConnectClientDialog.test.tsx | 190 +++ .../apps/gateways/ConnectClientDialog.tsx | 341 +++-- .../apps/gateways/CopyableGatewayUrl.test.tsx | 44 + .../apps/gateways/CopyableGatewayUrl.tsx | 56 + .../apps/gateways/EditGatewayDialog.test.tsx | 168 +++ .../pages/apps/gateways/EditGatewayDialog.tsx | 123 ++ ui/src/pages/apps/gateways/GatewayDetail.tsx | 47 +- ui/src/pages/apps/gateways/GatewaysList.tsx | 47 +- .../apps/gateways/gateway-helpers.test.ts | 28 + ui/src/pages/apps/gateways/gateway-helpers.ts | 39 + .../panels/GatewayActivityPanel.test.tsx | 147 ++ .../gateways/panels/GatewayActivityPanel.tsx | 210 ++- .../apps/gateways/panels/TokensPanel.test.tsx | 63 +- .../apps/gateways/panels/TokensPanel.tsx | 364 ++--- ui/src/pages/apps/store-cards.tsx | 4 +- ui/src/pages/tools/AuditTab.test.tsx | 37 +- ui/src/pages/tools/AuditTab.tsx | 73 +- ui/src/pages/tools/PoliciesTab.test.tsx | 282 ---- ui/src/pages/tools/PoliciesTab.tsx | 1192 ----------------- ui/src/pages/tools/ProfilesTab.tsx | 21 +- ui/src/pages/tools/RuntimeTab.test.tsx | 244 ---- ui/src/pages/tools/RuntimeTab.tsx | 660 --------- ui/src/pages/tools/ToolsAccess.test.tsx | 23 +- ui/src/pages/tools/ToolsAccess.tsx | 33 +- ui/src/pages/tools/tool-tabs.ts | 4 - .../stories/activity-feed.stories.tsx | 1 + ui/storybook/stories/policies-tab.stories.tsx | 319 ----- .../stories/runtime-health.stories.tsx | 246 ---- 100 files changed, 5465 insertions(+), 4305 deletions(-) create mode 100644 server/src/__tests__/managed-loopback-auth.test.ts create mode 100644 server/src/__tests__/managed-runtime-public-url.test.ts create mode 100644 server/src/services/tool-connection-activity.ts create mode 100644 ui/src/api/tools.test.ts create mode 100644 ui/src/pages/apps/agent-selector-contract.test.ts create mode 100644 ui/src/pages/apps/connection-owner.tsx delete mode 100644 ui/src/pages/apps/gateways/AppsSubNav.tsx create mode 100644 ui/src/pages/apps/gateways/ConnectClientDialog.test.tsx create mode 100644 ui/src/pages/apps/gateways/CopyableGatewayUrl.test.tsx create mode 100644 ui/src/pages/apps/gateways/CopyableGatewayUrl.tsx create mode 100644 ui/src/pages/apps/gateways/EditGatewayDialog.test.tsx create mode 100644 ui/src/pages/apps/gateways/EditGatewayDialog.tsx create mode 100644 ui/src/pages/apps/gateways/panels/GatewayActivityPanel.test.tsx delete mode 100644 ui/src/pages/tools/PoliciesTab.test.tsx delete mode 100644 ui/src/pages/tools/PoliciesTab.tsx delete mode 100644 ui/src/pages/tools/RuntimeTab.test.tsx delete mode 100644 ui/src/pages/tools/RuntimeTab.tsx delete mode 100644 ui/storybook/stories/policies-tab.stories.tsx delete mode 100644 ui/storybook/stories/runtime-health.stories.tsx diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index b31b2e60bf..f55d1dd924 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -1,5 +1,6 @@ import { describe,expect,it } from "vitest"; import { APP_DEFINITIONS } from "./app-definitions.generated.js"; +import { recommendedDefaultsForApp } from "./app-definitions.js"; import { appDefinitionsSchema } from "./validators/app-definition.js"; describe("AppDefinition catalog",()=>{ it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow()); @@ -10,6 +11,7 @@ describe("AppDefinition catalog",()=>{ expect(notion?.methods[0]?.defaults).toEqual({serverUrl:"https://mcp.notion.com/mcp"}); }); it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"])); + it("defaults S2-S4 write and destructive actions to ask-first",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods)expect(recommendedDefaultsForApp(app,method.key)).toEqual({access:"all_agents",askFirstRiskLevels:method.riskTier==="S1"?[]:["write","destructive"]})}); it("offers PostHog OAuth and API-key methods with broad defaults and advanced narrowing",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")?.defaultValue).toBe(false);expect(method.tenantFields?.find((field)=>field.key==="projectId")?.transport).toEqual({location:"header",name:"x-posthog-project-id"});expect(method.tenantFields?.filter((field)=>field.advanced).map((field)=>field.key)).toEqual(["features","tools","mode"]);expect(method.configRequirements).toBeUndefined();expect(method.requiredResourceFilters).toEqual(["project"])}}); it("enforces method and field invariants",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods){if(method.auth==="api_key")expect(method.keyPlacement).toBeTruthy();if(method.auth==="oauth")expect(method.ownershipModes.length).toBeGreaterThan(0);for(const field of method.credentialFields??[])if(field.required&&field.type!=="checkbox")expect(field.placeholder).toBeTruthy()}}); }); diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 74ba98ed8b..25a8dbf654 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -72,6 +72,7 @@ export interface WorkspaceCommandDefinition { kind: WorkspaceCommandKind; command: string | null; cwd: string | null; + port: number | null; lifecycle: "shared" | "ephemeral" | null; serviceIndex: number | null; disabledReason: string | null; diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index f8e1c5257a..7018f750de 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { connectionTokenRequestSchema, connectToolAppSchema, + createToolMcpGatewayTokenSchema, createToolConnectionSchema, startConnectionAuthorizationSchema, toolCredentialSecretRefSchema, @@ -10,6 +11,16 @@ import { } from "./tool-access.js"; describe("tool access validators", () => { + it("treats a gateway token owner note as optional", () => { + const parsed = createToolMcpGatewayTokenSchema.parse({ + name: "cursor-client", + clientLabel: "cursor-client", + expiresAt: "2026-12-01T00:00:00.000Z", + }); + + expect(parsed.ownerNote).toBe(""); + }); + it("defaults connection token subjects to app", () => { expect(connectionTokenRequestSchema.parse({})).toEqual({ subject: { type: "app" } }); }); diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 64968c574c..b7297ac7a5 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -599,7 +599,7 @@ export const createToolMcpGatewayTokenSchema = z.object({ subjectType: toolMcpGatewayTokenSubjectTypeSchema.default("gateway_client").optional(), subjectId: z.string().trim().min(1).max(240).optional().nullable(), clientLabel: z.string().trim().min(1).max(160), - ownerNote: z.string().trim().min(1).max(1000), + ownerNote: z.string().trim().max(1000).default(""), allowedActions: z.array(toolMcpGatewayTokenActionSchema).min(1).max(TOOL_MCP_GATEWAY_TOKEN_ACTIONS.length).default(["tools/list", "tools/call"]).optional(), expiresAt: z.coerce.date().optional().nullable(), expiryOverrideReason: z.string().trim().min(1).max(1000).optional().nullable(), diff --git a/packages/shared/src/workspace-commands.test.ts b/packages/shared/src/workspace-commands.test.ts index 11eca81c40..ee60912059 100644 --- a/packages/shared/src/workspace-commands.test.ts +++ b/packages/shared/src/workspace-commands.test.ts @@ -47,6 +47,7 @@ describe("workspace command helpers", () => { serviceName: "web", command: "pnpm dev", cwd: "/repo", + port: null, configIndex: null, }, ]); @@ -69,6 +70,7 @@ describe("workspace command helpers", () => { serviceName: "web", command: "pnpm dev", cwd: "/repo", + port: null, configIndex: null, }, ]); @@ -126,4 +128,52 @@ describe("workspace command helpers", () => { expect(match).toBeNull(); }); + + it("does not revive runtime history from a previously configured port", () => { + const command = findWorkspaceCommandDefinition({ + services: [ + { + name: "web", + command: "pnpm dev", + port: { type: "fixed", value: 42001 }, + }, + ], + }, "service:web"); + expect(command).toEqual(expect.objectContaining({ port: 42001 })); + + const match = matchWorkspaceRuntimeServiceToCommand(command!, [ + { + id: "runtime-old-port", + serviceName: "web", + command: "pnpm dev", + cwd: "/repo", + port: 42013, + configIndex: 0, + }, + { + id: "runtime-current-port", + serviceName: "web", + command: "pnpm dev", + cwd: "/repo", + port: 42001, + configIndex: 0, + }, + ]); + + expect(match).toEqual(expect.objectContaining({ id: "runtime-current-port" })); + }); + + it("does not treat an auto port preference as a fixed runtime identity", () => { + const command = findWorkspaceCommandDefinition({ + services: [ + { + name: "web", + command: "pnpm dev", + port: { type: "auto", value: 42001 }, + }, + ], + }, "service:web"); + + expect(command).toEqual(expect.objectContaining({ port: null })); + }); }); diff --git a/packages/shared/src/workspace-commands.ts b/packages/shared/src/workspace-commands.ts index e044e95ab2..c567b4f8d9 100644 --- a/packages/shared/src/workspace-commands.ts +++ b/packages/shared/src/workspace-commands.ts @@ -3,7 +3,7 @@ import { forceLoopbackBindInCommand } from "./runtime-exposure/loopback-bind.js" type WorkspaceRuntimeServiceMatchCandidate = & Pick - & Pick, "exposure">; + & Pick, "exposure" | "port">; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -15,6 +15,17 @@ function readNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function readConfiguredPort(value: unknown): number | null { + if (isRecord(value) && value.type === "auto") return null; + const candidate = isRecord(value) ? value.value : value; + return typeof candidate === "number" + && Number.isInteger(candidate) + && candidate > 0 + && candidate <= 65_535 + ? candidate + : null; +} + function slugify(value: string | null | undefined) { const normalized = (value ?? "") .trim() @@ -64,6 +75,7 @@ function buildWorkspaceCommandDefinition(input: { kind: input.kind, command: readNonEmptyString(input.entry.command), cwd: readNonEmptyString(input.entry.cwd), + port: readConfiguredPort(input.entry.port), lifecycle: input.kind === "service" ? input.entry.lifecycle === "ephemeral" @@ -168,7 +180,7 @@ export function findWorkspaceCommandDefinition( } export function scoreWorkspaceRuntimeServiceMatch( - command: Pick, + command: Pick, runtimeService: WorkspaceRuntimeServiceMatchCandidate, ) { const exposedCommandMatches = Boolean( @@ -186,11 +198,17 @@ export function scoreWorkspaceRuntimeServiceMatch( return -1; } + if (command.port !== null && runtimeService.port != null && runtimeService.port !== command.port) { + return -1; + } + if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) { - return runtimeService.configIndex === command.serviceIndex ? 100 : -1; + if (runtimeService.configIndex !== command.serviceIndex) return -1; + return 100 + (command.port !== null && runtimeService.port === command.port ? 8 : 0); } let score = 0; + if (command.port !== null && runtimeService.port === command.port) score += 8; if (runtimeService.serviceName === command.name) score += 4; if ((runtimeService.command ?? null) === (command.command ?? null)) score += 4; if ( @@ -206,7 +224,7 @@ export function scoreWorkspaceRuntimeServiceMatch( export function matchWorkspaceRuntimeServiceToCommand< T extends WorkspaceRuntimeServiceMatchCandidate, >( - command: Pick, + command: Pick, runtimeServices: T[] | null | undefined, ) { let bestMatch: T | null = null; diff --git a/scripts/smoke/posthog-live.mjs b/scripts/smoke/posthog-live.mjs index 69bb4cd13a..21885d0f15 100644 --- a/scripts/smoke/posthog-live.mjs +++ b/scripts/smoke/posthog-live.mjs @@ -20,16 +20,17 @@ const EXCLUDED_PROJECT_SWITCHERS = new Set(["switch-project", "switch-organizati const DEFAULT_AGENT_TIMEOUT_MS = 15 * 60_000; class SmokeFailure extends Error { - constructor(checkpoint, code) { + constructor(checkpoint, code, details = null) { super(`${checkpoint}:${code}`); this.name = "SmokeFailure"; this.checkpoint = checkpoint; this.code = code; + this.details = details; } } -function fail(checkpoint, code) { - throw new SmokeFailure(checkpoint, code); +function fail(checkpoint, code, details = null) { + throw new SmokeFailure(checkpoint, code, details); } function asArray(value, key) { @@ -93,10 +94,15 @@ function assertNoCredentialMaterial(value, secrets, checkpoint) { async function apiJson(request, baseUrl, method, pathname, data, checkpoint, expectedStatuses = [200]) { let response; try { + const origin = new URL(baseUrl).origin; response = await request.fetch(new URL(pathname, baseUrl).toString(), { method, ...(data === undefined ? {} : { data }), - headers: { accept: "application/json" }, + headers: { + accept: "application/json", + origin, + referer: `${origin}/`, + }, timeout: 30_000, }); } catch { @@ -132,25 +138,150 @@ async function expectVisible(locator, checkpoint, code, timeout = 30_000) { } } +async function gotoPaperclipPage( + page, + url, + readyLocator, + checkpoint, + code, + { attempts = 3, timeout = 15_000 } = {}, +) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + await readyLocator.waitFor({ state: "visible", timeout }); + return; + } catch { + if (attempt < attempts) await page.waitForTimeout(500); + } + } + fail(checkpoint, code); +} + +async function stabilizeCompanyContext(page, config, companyId) { + const galleryPath = `/api/companies/${companyId}/tools/gallery`; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const galleryResponsePromise = page.waitForResponse( + (response) => response.request().method() === "GET" + && new URL(response.url()).pathname === galleryPath, + { timeout: 30_000 }, + ); + await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps`, config.baseUrl).toString(), { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + const response = await galleryResponsePromise; + if (!response.ok()) continue; + const posthogAction = page.getByRole("button", { + name: /^(?:Connect for PostHog|Add another PostHog account)$/, + }).first(); + await posthogAction.waitFor({ state: "visible", timeout: 30_000 }); + // The company-prefixed route and selected-company provider settle in + // separate renders. Clicking the tile immediately can carry the prior + // company's gallery cache into the setup effect and redirect back out. + await page.waitForTimeout(2_000); + return; + } catch { + if (attempt < 3) await page.waitForTimeout(500); + } + } + fail("A.company-context", "posthog_gallery_context_missing"); +} + +async function openPosthogSetupFromGallery(page, config, companyId) { + for (let attempt = 1; attempt <= 3; attempt += 1) { + await stabilizeCompanyContext(page, config, companyId); + const addAnother = page.getByRole("button", { name: /^Add another PostHog account$/ }).first(); + const connect = page.getByRole("button", { name: /^Connect for PostHog$/ }).first(); + const action = await addAnother.isVisible().catch(() => false) ? addAnother : connect; + try { + await action.click({ timeout: 5_000 }); + await page.getByRole("button", { name: "Sign in with PostHog" }).waitFor({ + state: "visible", + timeout: 30_000, + }); + return; + } catch { + if (attempt < 3) await page.waitForTimeout(500); + } + } + fail("A.setup-route", "oauth_method_missing"); +} + +async function safePageState(page, resourceFailures, paperclipOrigin) { + let current; + try { + current = new URL(page.url()); + } catch { + return { location: "invalid", resourceFailures }; + } + const bodyText = await page.locator("body").innerText().catch(() => ""); + return { + location: current.origin === paperclipOrigin + ? `${current.hostname}${current.pathname}` + : current.hostname, + headingCount: await page.getByRole("heading").count().catch(() => 0), + buttonCount: await page.getByRole("button").count().catch(() => 0), + methodSignals: { + posthogSignIn: /sign in with posthog/i.test(bodyText), + personalApiKey: /personal api key/i.test(bodyText), + connectApp: /connect an app/i.test(bodyText), + }, + resourceFailures, + }; +} + async function clickVisibleButton(page, names) { for (const name of names) { - const button = page.getByRole("button", { name, exact: false }).filter({ visible: true }).first(); - if (await button.count()) { - try { - await button.click({ timeout: 2_000 }); - return true; - } catch { - // Provider pages often replace their form between locator creation and - // click. The next loop re-reads the current DOM. + for (const role of ["button", "link"]) { + const control = page.getByRole(role, { name, exact: false }).filter({ visible: true }).first(); + if (await control.count()) { + try { + await control.click({ timeout: 2_000 }); + return true; + } catch { + // Provider pages often replace their form between locator creation + // and click. The next loop re-reads the current DOM. + } } } } return false; } +async function selectPosthogCloudRegion(page) { + const region = (process.env.POSTHOG_CLOUD_REGION || "us").trim().toLowerCase(); + if (!new Set(["us", "eu"]).has(region)) { + fail("B.oauth-callback", "unsupported_cloud_region"); + } + const expectedHost = `${region}.posthog.com`; + const links = page.getByRole("link"); + for (let index = 0; index < await links.count(); index += 1) { + const link = links.nth(index); + const href = await link.getAttribute("href"); + if (!href) continue; + try { + const target = new URL(href, page.url()); + if (target.hostname !== expectedHost) continue; + await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 }); + return true; + } catch { + // The provider can replace this chooser while the link is being read. + // The next authorization-loop iteration re-evaluates it. + } + } + return false; +} + async function completePosthogAuthorization(page, config) { const paperclipOrigin = new URL(config.baseUrl).origin; - const deadline = Date.now() + 4 * 60_000; + const providerTimeoutMs = Number(process.env.POSTHOG_PROVIDER_TIMEOUT_MS || 4 * 60_000); + const deadline = Date.now() + (Number.isFinite(providerTimeoutMs) && providerTimeoutMs > 0 + ? providerTimeoutMs + : 4 * 60_000); + let providerState = null; + let credentialFormSubmitted = false; while (Date.now() < deadline) { let current; try { @@ -161,16 +292,79 @@ async function completePosthogAuthorization(page, config) { if (current.origin === paperclipOrigin && current.pathname.includes("/apps/")) return; const emailInput = page.locator('input[type="email"], input[name="email"], input[autocomplete="username"]').filter({ visible: true }).first(); + const passwordInput = page.locator('input[type="password"], input[name="password"], input[autocomplete="current-password"]').filter({ visible: true }).first(); + const identityFieldVisible = await emailInput.count() > 0; + const credentialFieldVisible = await passwordInput.count() > 0; + const credentialForm = page.locator("form").filter({ has: passwordInput }).first(); + const submitControl = credentialForm.locator('button[type="submit"], input[type="submit"]').filter({ visible: true }).first(); + const consentControlVisible = await page.getByRole("button", { + name: /^(?:authorize|allow|approve|grant access|accept)$/i, + }).first().isVisible().catch(() => false); + const bodyText = await page.locator("body").innerText().catch(() => ""); + const linkHrefs = await page.getByRole("link").evaluateAll((links) => + links.map((link) => link.getAttribute("href")).filter(Boolean) + ).catch(() => []); + const linkTargets = Array.from(new Set(linkHrefs.map((href) => { + try { + const target = new URL(href, current.origin); + return `${target.hostname}${target.pathname}`; + } catch { + return "invalid"; + } + }))); + providerState = { + host: current.hostname, + path: current.pathname.slice(0, 200), + identityFieldVisible, + credentialFieldVisible, + credentialFormVisible: await credentialForm.count() > 0, + submitControlVisible: await submitControl.count() > 0, + submitControlDisabled: await submitControl.isDisabled().catch(() => false), + consentControlVisible, + alertCount: await page.getByRole("alert").count(), + headingCount: await page.getByRole("heading").count(), + buttonCount: await page.getByRole("button").count(), + linkCount: await page.getByRole("link").count(), + linkTargets, + frameCount: page.frames().length, + semanticSignals: { + signIn: /\b(?:sign in|log in)\b/i.test(bodyText), + continue: /\bcontinue\b/i.test(bodyText), + consent: /\b(?:authorize|allow|approve|grant access|accept)\b/i.test(bodyText), + loading: /\b(?:loading|preparing|opening|redirecting)\b/i.test(bodyText), + workspace: /\b(?:workspace|organization|project)\b/i.test(bodyText), + error: /\b(?:error|invalid|failed|problem|went wrong)\b/i.test(bodyText), + }, + }; + if (current.hostname === "oauth.posthog.com" && await selectPosthogCloudRegion(page)) { + await page.waitForTimeout(500); + continue; + } if (await emailInput.count()) { const currentValue = await emailInput.inputValue().catch(() => ""); if (!currentValue) await emailInput.fill(config.email); } - const passwordInput = page.locator('input[type="password"], input[name="password"], input[autocomplete="current-password"]').filter({ visible: true }).first(); if (await passwordInput.count()) { const currentValue = await passwordInput.inputValue().catch(() => ""); if (!currentValue) await passwordInput.fill(config.password); - await clickVisibleButton(page, [/^sign in$/i, /^log in$/i, /^continue$/i, /sign in with email/i]); + if (!credentialFormSubmitted) { + if (await submitControl.count() && !await submitControl.isDisabled().catch(() => true)) { + await submitControl.click({ noWaitAfter: true, timeout: 2_000 }).catch(() => {}); + } else { + const submitted = await clickVisibleButton(page, [ + /^sign in$/i, + /^log in$/i, + /^login$/i, + /^continue$/i, + /sign in with email/i, + /log in with email/i, + /login with email/i, + ]); + if (!submitted) await passwordInput.press("Enter").catch(() => {}); + } + credentialFormSubmitted = true; + } } else if (await emailInput.count()) { await clickVisibleButton(page, [/^continue$/i, /^next$/i, /continue with email/i, /sign in with email/i]); } else { @@ -187,7 +381,7 @@ async function completePosthogAuthorization(page, config) { } await page.waitForTimeout(500); } - fail("B.oauth-callback", "provider_authorization_timed_out"); + fail("B.oauth-callback", "provider_authorization_timed_out", { providerState }); } async function safeScreenshot(page, outputPath, config, checkpoint) { @@ -246,7 +440,30 @@ async function finishAgentOnlySetup(request, config, companyId, connectionId, ca ); } -async function cleanupConnection(request, config, companyId, connectionId, connectionName) { +async function findConnectionIdByName(request, config, companyId, connectionName) { + const connectionsResponse = await apiJson( + request, + config.baseUrl, + "GET", + `/api/companies/${companyId}/tools/connections`, + undefined, + "F.cleanup-recovery", + ); + const matching = asArray(connectionsResponse, "connections").filter( + (connection) => connection.name === connectionName && connection.status !== "archived", + ); + if (matching.length > 1) fail("F.cleanup-recovery", "duplicate_test_connections"); + return matching[0]?.id ?? null; +} + +async function cleanupConnection( + request, + config, + companyId, + connectionId, + connectionName, + { requireInstalledState = true } = {}, +) { const removed = await apiJson( request, config.baseUrl, @@ -256,11 +473,12 @@ async function cleanupConnection(request, config, companyId, connectionId, conne "F.cleanup", ); const receipt = removed.removal; - if (!receipt - || receipt.installsRemoved < 1 + if (!receipt || (requireInstalledState && ( + receipt.installsRemoved < 1 || receipt.appProfileBindingsRemoved < 1 || receipt.credentialRefsCleared + receipt.secretsRevoked < 1 - || !["deleted", "archived"].includes(receipt.appProfile)) { + || !["deleted", "archived"].includes(receipt.appProfile) + ))) { fail("F.cleanup", "incomplete_removal_receipt"); } @@ -336,10 +554,13 @@ async function runSmoke({ config, chromium }) { let browser; let context; + let page; let connectionId = null; let companyId = null; let cleanupComplete = false; let caughtFailure = null; + let activeCheckpoint = "A.browser-launch"; + const resourceFailures = []; try { browser = await chromium.launch({ headless: process.env.POSTHOG_SMOKE_HEADED !== "1" }); @@ -348,10 +569,31 @@ async function runSmoke({ config, chromium }) { acceptDownloads: false, serviceWorkers: "block", }); - const page = await context.newPage(); + page = await context.newPage(); + page.on("requestfailed", (request) => { + if (!["document", "script", "stylesheet", "xhr", "fetch"].includes(request.resourceType())) return; + try { + const target = new URL(request.url()); + if (target.origin !== new URL(config.baseUrl).origin) return; + resourceFailures.push({ + target: `${target.hostname}${target.pathname}`, + resourceType: request.resourceType(), + error: request.failure()?.errorText ?? "unknown", + }); + if (resourceFailures.length > 12) resourceFailures.shift(); + } catch { + // Ignore malformed resource URLs rather than copying them into evidence. + } + }); - await page.goto(new URL("/auth?next=/", config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.locator("#email"), "A.paperclip-login", "email_field_missing"); + activeCheckpoint = "A.paperclip-login"; + await gotoPaperclipPage( + page, + new URL("/auth?next=/", config.baseUrl).toString(), + page.locator("#email"), + "A.paperclip-login", + "email_field_missing", + ); await page.locator("#email").fill(config.email); await page.locator("#password").fill(config.password); const loginResponsePromise = page.waitForResponse((response) => @@ -364,11 +606,13 @@ async function runSmoke({ config, chromium }) { fail("A.paperclip-login", "login_redirect_missing"); }); + activeCheckpoint = "A.company-selection"; const companiesResponse = await apiJson(context.request, config.baseUrl, "GET", "/api/companies", undefined, "A.company-selection"); const company = asArray(companiesResponse, "companies").find((candidate) => candidate.issuePrefix === TARGET_COMPANY_PREFIX); if (!company) fail("A.company-selection", "pap_company_missing"); companyId = company.id; + activeCheckpoint = "C.agent-scope"; const agentsResponse = await apiJson( context.request, config.baseUrl, @@ -380,12 +624,11 @@ async function runSmoke({ config, chromium }) { const agent = asArray(agentsResponse, "agents").find((candidate) => candidate.name === TARGET_AGENT_NAME); if (!agent) fail("C.agent-scope", "codex_coder_pro_missing"); - const setupUrl = new URL(`/${TARGET_COMPANY_PREFIX}/apps/connect?byo=1&appKey=posthog&stage=setup`, config.baseUrl); - await page.goto(setupUrl.toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.getByRole("heading", { name: "Connect PostHog" }), "A.setup-route", "posthog_setup_missing"); - await expectVisible(page.getByRole("button", { name: "Sign in with PostHog" }), "A.setup-route", "oauth_method_missing"); + activeCheckpoint = "A.setup-route"; + await openPosthogSetupFromGallery(page, config, companyId); await expectVisible(page.getByRole("button", { name: "Use a personal API key" }), "A.setup-route", "api_key_method_missing"); + activeCheckpoint = "B.oauth-setup"; await page.getByRole("button", { name: "Sign in with PostHog" }).click(); const nameInput = page.locator('input[placeholder="My app"]'); await nameInput.fill(connectionName); @@ -402,28 +645,46 @@ async function runSmoke({ config, chromium }) { const responseMode = page.locator("label", { hasText: "Tool response mode" }).locator("..").locator("select"); if (await responseMode.inputValue() !== "tools") fail("B.oauth-setup", "individual_tools_mode_not_selected"); - const connectResponsePromise = page.waitForResponse((response) => { - const target = new URL(response.url()); - return response.request().method() === "POST" - && target.pathname === `/api/companies/${companyId}/tools/apps/connect`; - }); - await page.getByRole("button", { name: "Continue to sign in" }).click(); + activeCheckpoint = "B.oauth-start"; + const connectResponsePromise = page.waitForResponse( + (response) => { + const target = new URL(response.url()); + return response.request().method() === "POST" + && target.pathname === `/api/companies/${companyId}/tools/apps/connect`; + }, + { timeout: 120_000 }, + ); + await page.getByRole("button", { name: "Continue to sign in" }).click({ noWaitAfter: true }); const connectResponse = await connectResponsePromise; if (!connectResponse.ok()) fail("B.oauth-start", `http_${connectResponse.status()}`); - let connectResult; + let connectResult = null; try { connectResult = await connectResponse.json(); } catch { - fail("B.oauth-start", "invalid_json"); + // A successful create immediately redirects the page to PostHog. Chromium + // can discard that response body during the cross-origin navigation, so + // recover the uniquely named draft instead of orphaning it. } - connectionId = connectResult.connectionId; + connectionId = connectResult?.connectionId ?? await waitFor( + "B.oauth-start", + () => findConnectionIdByName(context.request, config, companyId, connectionName), + { timeoutMs: 15_000, intervalMs: 500 }, + ); if (typeof connectionId !== "string" || !connectionId) fail("B.oauth-start", "connection_id_missing"); + activeCheckpoint = "B.oauth-callback"; await completePosthogAuthorization(page, config); const cleanSetupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`; - await page.goto(new URL(cleanSetupPath, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.getByText("PostHog connected", { exact: true }), "B.oauth-callback", "connected_state_missing", 45_000); + await gotoPaperclipPage( + page, + new URL(cleanSetupPath, config.baseUrl).toString(), + page.getByText("PostHog connected", { exact: true }), + "B.oauth-callback", + "connected_state_missing", + { attempts: 3, timeout: 30_000 }, + ); + activeCheckpoint = "C.connection-detail"; let connection = await apiJson( context.request, config.baseUrl, @@ -446,6 +707,7 @@ async function runSmoke({ config, chromium }) { await safeScreenshot(page, screenshotFile(outputDirectory, connectedShot), config, "F.connected-screenshot"); summary.screenshots.push(connectedShot); + activeCheckpoint = "C.catalog-policy"; let catalogResponse = await apiJson( context.request, config.baseUrl, @@ -538,8 +800,14 @@ async function runSmoke({ config, chromium }) { catalogRefresh: "succeeded", }; - await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.getByText("Who can use it", { exact: true }), "C.permissions-ui", "permissions_panel_missing"); + activeCheckpoint = "C.permissions-ui"; + await gotoPaperclipPage( + page, + new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`, config.baseUrl).toString(), + page.getByText("Who can use it", { exact: true }), + "C.permissions-ui", + "permissions_panel_missing", + ); const projectGetPermission = page.locator(`[data-action-id="${facts.projectGet.id}"] select`); const projectSettingsPermission = page.locator(`[data-action-id="${facts.projectSettings.id}"] select`); await expectVisible(projectGetPermission, "C.permissions-ui", "project_get_permission_missing"); @@ -551,8 +819,14 @@ async function runSmoke({ config, chromium }) { await safeScreenshot(page, screenshotFile(outputDirectory, permissionsShot), config, "F.permissions-screenshot"); summary.screenshots.push(permissionsShot); - await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/test`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.getByLabel("Choose which agent to test as"), "D.test-panel", "agent_picker_missing"); + activeCheckpoint = "D.test-panel"; + await gotoPaperclipPage( + page, + new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/test`, config.baseUrl).toString(), + page.getByLabel("Choose which agent to test as"), + "D.test-panel", + "agent_picker_missing", + ); await page.getByLabel("Choose which agent to test as").click(); await page.getByLabel("Search agents").fill(TARGET_AGENT_NAME); await page.getByRole("button", { name: new RegExp(`^${escapeRegex(TARGET_AGENT_NAME)}`) }).click(); @@ -563,6 +837,7 @@ async function runSmoke({ config, chromium }) { await actionRow.click(); await expectVisible(page.getByText("This action takes no inputs."), "D.test-panel", "empty_input_form_missing"); + activeCheckpoint = "D.project-get"; const boardTestStartedAt = Date.now(); const testCallResponsePromise = page.waitForResponse((response) => response.request().method() === "POST" @@ -611,6 +886,7 @@ async function runSmoke({ config, chromium }) { durationMs: Date.now() - boardTestStartedAt, }; + activeCheckpoint = "E.create-proof-issue"; const parentIssueId = process.env.POSTHOG_PROOF_PARENT_ISSUE_ID || process.env.PAPERCLIP_TASK_ID; if (!parentIssueId) fail("E.create-proof-issue", "parent_issue_id_missing"); const child = await apiJson( @@ -640,6 +916,7 @@ async function runSmoke({ config, chromium }) { [201], ); if (child.status !== "todo") fail("E.create-proof-issue", "child_not_created_todo"); + activeCheckpoint = "E.fresh-agent-run"; const observedStatuses = new Set(["todo"]); const finishedChild = await waitFor("E.fresh-agent-run", async () => { const issue = await apiJson(context.request, config.baseUrl, "GET", `/api/issues/${child.id}`, undefined, "E.fresh-agent-run"); @@ -731,30 +1008,73 @@ async function runSmoke({ config, chromium }) { durationMs: agentEvent.latencyMs, }; - await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/issues/${child.identifier}`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.getByText(child.title, { exact: true }).first(), "F.child-screenshot", "child_issue_missing"); + activeCheckpoint = "F.evidence"; + await gotoPaperclipPage( + page, + new URL(`/${TARGET_COMPANY_PREFIX}/issues/${child.identifier}`, config.baseUrl).toString(), + page.getByText(child.title, { exact: true }).first(), + "F.child-screenshot", + "child_issue_missing", + ); const childShot = "04-fresh-agent-proof.png"; await safeScreenshot(page, screenshotFile(outputDirectory, childShot), config, "F.child-screenshot"); summary.screenshots.push(childShot); - await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/activity`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); - await expectVisible(page.getByText(PROJECT_GET, { exact: false }).first(), "F.activity-screenshot", "project_get_activity_missing"); + await gotoPaperclipPage( + page, + new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/activity`, config.baseUrl).toString(), + page.getByText(PROJECT_GET, { exact: false }).first(), + "F.activity-screenshot", + "project_get_activity_missing", + ); const activityShot = "05-redacted-activity.png"; await safeScreenshot(page, screenshotFile(outputDirectory, activityShot), config, "F.activity-screenshot"); summary.screenshots.push(activityShot); + activeCheckpoint = "F.cleanup"; summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName); cleanupComplete = true; summary.passed = true; } catch (error) { - caughtFailure = error instanceof SmokeFailure ? error : new SmokeFailure("unexpected", "unexpected_error"); + caughtFailure = error instanceof SmokeFailure ? error : new SmokeFailure(activeCheckpoint, "unexpected_error"); + if (page) { + caughtFailure.details = { + ...(caughtFailure.details ?? {}), + pageState: await safePageState(page, resourceFailures, new URL(config.baseUrl).origin), + }; + } } finally { + if (!connectionId && companyId && context) { + try { + connectionId = await findConnectionIdByName( + context.request, + config, + companyId, + connectionName, + ); + } catch (error) { + summary.cleanup = { + completed: false, + code: error instanceof SmokeFailure ? error.code : "cleanup_recovery_failed", + }; + } + } if (connectionId && companyId && context && !cleanupComplete) { try { - summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName); + summary.cleanup = await cleanupConnection( + context.request, + config, + companyId, + connectionId, + connectionName, + { requireInstalledState: false }, + ); cleanupComplete = true; - } catch { - summary.cleanup = { completed: false, code: "cleanup_failed" }; + } catch (error) { + summary.cleanup = { + completed: false, + code: error instanceof SmokeFailure ? error.code : "cleanup_failed", + }; if (!caughtFailure) caughtFailure = new SmokeFailure("F.cleanup", "cleanup_failed"); } } @@ -764,7 +1084,11 @@ async function runSmoke({ config, chromium }) { summary.completedAt = new Date().toISOString(); if (caughtFailure) { - summary.failure = { checkpoint: caughtFailure.checkpoint, code: caughtFailure.code }; + summary.failure = { + checkpoint: caughtFailure.checkpoint, + code: caughtFailure.code, + ...(caughtFailure.details ? { details: caughtFailure.details } : {}), + }; } assertSanitizedEvidence(summary); const summaryPath = path.join(outputDirectory, "summary.json"); diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts index 50aebc6dfa..31798ea7e2 100644 --- a/server/src/__tests__/better-auth.test.ts +++ b/server/src/__tests__/better-auth.test.ts @@ -179,6 +179,35 @@ describe("Better Auth cookie scoping", () => { })).toBe(false); }); + it("disables secure cookies only for HTTP loopback requests in a managed HTTPS runtime", () => { + const managedRuntimeInput = { + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://worktree.example.test", + publicUrl: "https://worktree.example.test", + managedRuntimePublicUrl: "https://worktree.example.test", + } as const; + + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email", + } as Parameters[0])).toBe(true); + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + requestUrl: "https://worktree.example.test/api/auth/sign-in/email", + } as Parameters[0])).toBe(false); + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + managedRuntimePublicUrl: undefined, + requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email", + } as Parameters[0])).toBe(false); + expect(shouldDisableSecureAuthCookies({ + ...managedRuntimeInput, + requestUrl: "http://board.example.test:42013/api/auth/sign-in/email", + } as Parameters[0])).toBe(false); + }); + it("adds hostname port variants for authenticated mode on non-default ports", () => { const trustedOrigins = deriveAuthTrustedOrigins({ deploymentMode: "authenticated", diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 421525ab89..48431a003b 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -3876,7 +3876,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(comments).toHaveLength(0); }, 20_000); - it("returns full details at the observed volume without multiplying unconfigured shared service history", async () => { + it("keeps a large collection DB-only while a concurrent health-style query remains responsive", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -3938,9 +3938,21 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { })), ); - const workspaces = await svc.list(companyId); + const inspectGitCloseReadiness = vi.fn(async () => { + throw new Error("collection inventory must not inspect git worktrees"); + }); + const inventoryService = executionWorkspaceService(db, { inspectGitCloseReadiness }); + const inventoryPromise = inventoryService.list(companyId); + const healthResponsive = await Promise.race([ + db.execute(sql`select 1 as ok`).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 2_000)), + ]); + const workspaces = await inventoryPromise; + expect(healthResponsive).toBe(true); + expect(inspectGitCloseReadiness).not.toHaveBeenCalled(); expect(workspaces).toHaveLength(workspaceCount); + expect(workspaces.every((workspace) => workspace.deliveryState === "unknown")).toBe(true); expect(workspaces.reduce((count, workspace) => count + (workspace.runtimeServices?.length ?? 0), 0)).toBe(0); expect(JSON.stringify(workspaces).length).toBeLessThan(12_000_000); @@ -4489,7 +4501,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { projectUrlKey: "workspaces", projectName: "Workspaces", branchName: "paperclip/a", - serviceCount: 2, + serviceCount: 1, runningServiceCount: 1, primaryServiceUrl: "http://localhost:3100", primaryServiceUrlRunning: true, diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 0724649775..664e9ff127 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -57,7 +57,10 @@ const PUBLIC_BASE_URL = "https://paperclip.fixture.test"; const REDIRECT_URI = `${PUBLIC_BASE_URL}/api/tools/oauth/callback`; const CLIENT_METADATA_DOCUMENT_URL = `${PUBLIC_BASE_URL}/api/tools/oauth/client-metadata`; -const MCP_ORIGIN = "https://mcp.fixture.test"; +// A public IP literal keeps the global-fetch protocol fixture deterministic. +// Hostname dispatch is intentionally DNS-pinned even in local/private mode, so +// a made-up test hostname would correctly fail DNS before reaching this mock. +const MCP_ORIGIN = "https://8.8.8.8"; const MCP_URL = `${MCP_ORIGIN}/mcp`; /** A pathful issuer, so RFC 8414 well-known insertion is actually exercised. */ const ISSUER = `${MCP_ORIGIN}/tenant/acme`; @@ -1554,4 +1557,29 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { }); expect(JSON.stringify(response.body)).not.toContain(company.id); }); + + it("uses the managed runtime origin when no explicit callback origin is configured", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", ""); + vi.stubEnv("BETTER_AUTH_URL", ""); + vi.stubEnv("BETTER_AUTH_BASE_URL", ""); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "https://worktree.tail29c1aa.ts.net"); + const app = createRouteApp(db); + + const response = await request(app).get("/api/tools/oauth/client-metadata").expect(200); + + expect(response.body.redirect_uris).toEqual([ + "https://worktree.tail29c1aa.ts.net/api/tools/oauth/callback", + ]); + }); + + it("keeps an explicit callback origin ahead of managed runtime inference", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", PUBLIC_BASE_URL); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "https://inferred.tail29c1aa.ts.net"); + const app = createRouteApp(db); + + const response = await request(app).get("/api/tools/oauth/client-metadata").expect(200); + + expect(response.body.redirect_uris).toEqual([REDIRECT_URI]); + }); }); diff --git a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts index fe16ee5b62..d7b8958f79 100644 --- a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts +++ b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts @@ -21,7 +21,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js"; +import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -240,4 +240,84 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { details: expect.objectContaining({ runId, deliveredServerCount: 0 }), }); }); + + it("injects only managed gateways whose profile connections are installed for the agent", async () => { + const [company] = await db.insert(companies).values({ + name: `Managed gateway installs ${randomUUID()}`, + issuePrefix: `MG${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning(); + const [agent] = await db.insert(agents).values({ + companyId: company!.id, + name: "Managed Gateway Agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + }).returning(); + const [application] = await db.insert(toolApplications).values({ + companyId: company!.id, + applicationKey: `managed-gateway-${randomUUID().slice(0, 8)}`, + name: "Managed Gateway App", + type: "mcp_http", + status: "active", + }).returning(); + const connections = await db.insert(toolConnections).values([ + { + companyId: company!.id, + applicationId: application!.id, + name: "Installed gateway connection", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + }, + { + companyId: company!.id, + applicationId: application!.id, + name: "Uninstalled gateway connection", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + }, + ]).returning(); + const profiles = await db.insert(toolProfiles).values(connections.map((connection) => ({ + companyId: company!.id, + profileKey: `gateway:${connection.id}`, + name: connection.name, + defaultAction: "deny" as const, + }))).returning(); + await db.insert(toolProfileEntries).values(profiles.map((profile, index) => ({ + companyId: company!.id, + profileId: profile.id, + selectorType: "connection" as const, + effect: "include" as const, + connectionId: connections[index]!.id, + }))); + const gateways = await db.insert(toolMcpGateways).values(profiles.map((profile, index) => ({ + companyId: company!.id, + name: `${connections[index]!.name} gateway`, + slug: `gateway-${index}-${randomUUID().slice(0, 8)}`, + profileId: profile.id, + status: "active" as const, + }))).returning(); + await db.insert(toolConnectionInstalls).values({ + companyId: company!.id, + connectionId: connections[0]!.id, + targetType: "agent", + targetId: agent!.id, + }); + + const config = await createManagedMcpRunConfig({ + db, + agent: agent!, + runId: randomUUID(), + config: {}, + projectId: null, + issueId: null, + }); + + expect(config?.gateways).toHaveLength(1); + expect(config?.gateways[0]).toMatchObject({ id: gateways[0]!.id, name: gateways[0]!.name }); + expect(config?.gateways.some((gateway) => gateway.id === gateways[1]!.id)).toBe(false); + }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7b7fa63e01..427fef0fa6 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -75,6 +75,10 @@ describe("human invite roles", () => { { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]); }); @@ -87,6 +91,10 @@ describe("human invite roles", () => { { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]); }); diff --git a/server/src/__tests__/managed-loopback-auth.test.ts b/server/src/__tests__/managed-loopback-auth.test.ts new file mode 100644 index 0000000000..9b3ee70460 --- /dev/null +++ b/server/src/__tests__/managed-loopback-auth.test.ts @@ -0,0 +1,101 @@ +import { createDb } from "@paperclipai/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Config } from "../config.js"; +import { createBetterAuthInstance } from "../auth/better-auth.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping managed loopback auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function authRequest(origin: string, path: string, init?: RequestInit): Request { + return new Request(`${origin}/api/auth${path}`, { + ...init, + headers: { + origin, + "content-type": "application/json", + ...init?.headers, + }, + }); +} + +function sessionCookie(response: Response): string { + const cookie = response.headers + .getSetCookie() + .find((value) => value.includes(".session_token=")); + expect(cookie).toBeDefined(); + return cookie!; +} + +describeEmbeddedPostgres("managed runtime loopback auth cookies", () => { + const publicOrigin = "https://worktree.example.test"; + const loopbackOrigin = "http://127.0.0.1:42013"; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-managed-loopback-auth-"); + }, 20_000); + + afterAll(async () => { + vi.unstubAllEnvs(); + await tempDb?.cleanup(); + }); + + it("uses a loopback sign-in cookie on the next request while keeping the public cookie secure", async () => { + vi.stubEnv("BETTER_AUTH_SECRET", "managed-loopback-auth-test-secret"); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", publicOrigin); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + + const db = createDb(tempDb!.connectionString); + const config = { + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "explicit", + authPublicBaseUrl: publicOrigin, + authDisableSignUp: false, + } as Config; + const auth = createBetterAuthInstance(db, config, [publicOrigin, loopbackOrigin]); + const credentials = { + name: "Loopback Operator", + email: "loopback-operator@example.test", + password: "correct-horse-battery-staple", + }; + + const signUpResponse = await auth.handler(authRequest(publicOrigin, "/sign-up/email", { + method: "POST", + body: JSON.stringify(credentials), + })); + expect(signUpResponse.status).toBe(200); + expect(sessionCookie(signUpResponse)).toMatch(/;\s*Secure(?:;|$)/i); + + const signInResponse = await auth.handler(authRequest(loopbackOrigin, "/sign-in/email", { + method: "POST", + body: JSON.stringify({ + email: credentials.email, + password: credentials.password, + }), + })); + expect(signInResponse.status).toBe(200); + const loopbackCookie = sessionCookie(signInResponse); + expect(loopbackCookie).not.toMatch(/;\s*Secure(?:;|$)/i); + + const getSessionResponse = await auth.handler(authRequest(loopbackOrigin, "/get-session", { + method: "GET", + headers: { + cookie: loopbackCookie.split(";", 1)[0], + }, + })); + expect(getSessionResponse.status).toBe(200); + await expect(getSessionResponse.json()).resolves.toMatchObject({ + user: { email: credentials.email }, + }); + }); +}); diff --git a/server/src/__tests__/managed-runtime-public-url.test.ts b/server/src/__tests__/managed-runtime-public-url.test.ts new file mode 100644 index 0000000000..6cfa8a056f --- /dev/null +++ b/server/src/__tests__/managed-runtime-public-url.test.ts @@ -0,0 +1,45 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadConfig } from "../config.js"; + +const missingConfigPath = path.join(os.tmpdir(), `paperclip-managed-runtime-config-${process.pid}.json`); + +function useIsolatedConfigEnvironment() { + vi.stubEnv("PAPERCLIP_CONFIG", missingConfigPath); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", ""); + vi.stubEnv("BETTER_AUTH_URL", ""); + vi.stubEnv("BETTER_AUTH_BASE_URL", ""); + vi.stubEnv("PAPERCLIP_AUTH_BASE_URL_MODE", ""); + vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", "local_trusted"); + vi.stubEnv("PAPERCLIP_DEPLOYMENT_EXPOSURE", "private"); + vi.stubEnv("PAPERCLIP_BIND", "loopback"); + vi.stubEnv("HOST", "127.0.0.1"); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("managed runtime public URL config", () => { + it("configures Better Auth from the managed runtime fallback", () => { + useIsolatedConfigEnvironment(); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "https://worktree.tail29c1aa.ts.net"); + + const config = loadConfig(); + + expect(config.authPublicBaseUrl).toBe("https://worktree.tail29c1aa.ts.net"); + expect(config.authBaseUrlMode).toBe("explicit"); + }); + + it("keeps explicit operator configuration ahead of the managed fallback", () => { + useIsolatedConfigEnvironment(); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://operator.example.com"); + vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "https://inferred.tail29c1aa.ts.net"); + + const config = loadConfig(); + + expect(config.authPublicBaseUrl).toBe("https://operator.example.com"); + }); +}); diff --git a/server/src/__tests__/remote-http-endpoint-guard.test.ts b/server/src/__tests__/remote-http-endpoint-guard.test.ts index 8361355487..3602be470f 100644 --- a/server/src/__tests__/remote-http-endpoint-guard.test.ts +++ b/server/src/__tests__/remote-http-endpoint-guard.test.ts @@ -22,6 +22,45 @@ describe("remote HTTP endpoint guard", () => { )).resolves.toBeUndefined(); }); + it.each([ + "169.254.0.1", + "169.254.169.254", + "::ffff:169.254.169.254", + "::ffff:a9fe:a9fe", + "fe80::1", + "febf::1", + ])("always rejects link-local literal %s when private networking is allowed", async (address) => { + const url = address.includes(":") ? `http://[${address}]/mcp` : `http://${address}/mcp`; + await expect(assertPublicRemoteHttpEndpoint( + new URL(url), + { allowPrivateNetwork: true }, + guardError, + )).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); + }); + + it.each(["169.254.42.1", "fe80::1234"])( + "always rejects link-local DNS answer %s when private networking is allowed", + async (address) => { + await expect(assertPublicRemoteHttpEndpoint( + new URL("https://operator-endpoint.example/mcp"), + { allowPrivateNetwork: true, lookup: async () => [{ address, family: address.includes(":") ? 6 : 4 }] }, + guardError, + )).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); + }, + ); + + it.each(["127.0.0.1", "10.1.2.3", "fd00::1"])( + "allows intended private address %s when private networking is allowed", + async (address) => { + const url = address.includes(":") ? `http://[${address}]/mcp` : `http://${address}/mcp`; + await expect(assertPublicRemoteHttpEndpoint( + new URL(url), + { allowPrivateNetwork: true }, + guardError, + )).resolves.toBeUndefined(); + }, + ); + it.each([ "http://[2001::1]/mcp", "http://[2001:20::1]/mcp", diff --git a/server/src/__tests__/remote-http-rebinding.test.ts b/server/src/__tests__/remote-http-rebinding.test.ts index 1476673758..7e77620423 100644 --- a/server/src/__tests__/remote-http-rebinding.test.ts +++ b/server/src/__tests__/remote-http-rebinding.test.ts @@ -484,9 +484,7 @@ describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { expect(calls).toEqual(["POST https://93.184.216.34/mcp manual"]); }); - it("uses platform fetch when the deployment allows private endpoints", async () => { - // Nothing to pin: an operator who is allowed to point at 127.0.0.1 directly - // gains nothing from rebinding, so this mode keeps `fetch` semantics. + it("uses platform fetch for an allowed private IP literal", async () => { const calls: string[] = []; const response = await guardedRemoteHttpFetch("http://127.0.0.1:9/mcp", {}, { allowPrivateNetwork: true, @@ -503,4 +501,38 @@ describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { expect(response.status).toBe(200); expect(calls).toEqual(["http://127.0.0.1:9/mcp"]); }); + + it("never invokes platform fetch for a link-local literal in private mode", async () => { + const calls: string[] = []; + await expect(guardedRemoteHttpFetch("http://169.254.169.254/latest/meta-data/", {}, { + allowPrivateNetwork: true, + error: guardError, + unpinnedFetch: async (input) => { + calls.push(String(input)); + return new Response("{}", { status: 200 }); + }, + })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); + expect(calls).toEqual([]); + }); + + it("pins an allowed private hostname and rejects a link-local peer before request bytes", async () => { + const internal = await startServer(); + const factory: RemoteHttpSocketFactory = () => { + const socket = netConnect({ host: "127.0.0.1", port: internal.port }); + openSockets.push(socket); + Object.defineProperty(socket, "remoteAddress", { get: () => "169.254.169.254", configurable: true }); + return socket; + }; + + await expect(guardedRemoteHttpFetch("http://lan-service.example/mcp", {}, { + allowPrivateNetwork: true, + lookup: async () => [{ address: "10.0.0.8", family: 4 }], + socketFactory: factory, + error: guardError, + unpinnedFetch: async () => { + throw new Error("hostnames must remain pinned"); + }, + })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); + expect(internal.requests).toHaveLength(0); + }); }); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 54756413bb..791df7154b 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -40,7 +40,7 @@ const { from: vi.fn(() => ({ where: vi.fn(async () => []) })), })), }) as never); - const detectPortMock = vi.fn(async (port: number) => port); + const detectPortMock = vi.fn(async ({ port }: { port: number; hostname: string }) => port); const deriveAuthTrustedOriginsMock = vi.fn(() => []); const resolveHeartbeatSchedulingSuppressionMock = vi.fn(() => ({ suppressed: false, @@ -610,6 +610,20 @@ describe("startServer authenticated auth origin setup", () => { process.env.BETTER_AUTH_SECRET = "test-secret"; }); + it("checks port availability on the configured bind host", async () => { + loadConfigMock.mockReturnValue(buildTestConfig({ + host: "127.0.0.1", + port: 3210, + })); + + await startServer(); + + expect(detectPortMock).toHaveBeenCalledWith({ + port: 3210, + hostname: "127.0.0.1", + }); + }); + it("derives trusted origins from the detected listen port before auth initializes", async () => { loadConfigMock.mockReturnValue(buildTestConfig({ port: 3210, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index be855ecc29..bd62ba102a 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -42,17 +42,43 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js"; +import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService as toolAccessServiceBase } from "../services/tool-access.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { secretService } from "../services/secrets.js"; import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js"; -import { createToolGatewayService, type ToolGatewayService } from "../services/tool-gateway.js"; +import { createToolGatewayService as createToolGatewayServiceBase, type ToolGatewayService } from "../services/tool-gateway.js"; import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +/** + * This suite predates the DNS-pinned HTTP transport and deliberately models + * remote servers with global fetch fixtures. Keep those protocol fixtures + * deterministic while the dedicated rebinding suite exercises real pinning. + */ +function toolAccessService( + db: ReturnType, + options: Parameters[1] = {}, +) { + return toolAccessServiceBase(db, { + remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], + remoteHttpRequest: async (url, init) => fetch(url, init), + ...options, + }); +} + +function createToolGatewayService( + db: ReturnType, + options: NonNullable[1]> = {}, +) { + return createToolGatewayServiceBase(db, { + remoteHttpRequest: async (url, init) => fetch(url, init), + ...options, + }); +} + async function createCompany(db: ReturnType) { return db .insert(companies) @@ -121,7 +147,11 @@ function createRouteApp( db: ReturnType, actor?: Express.Request["actor"], toolGateway?: ToolGatewayService, - deployment?: { deploymentMode: "authenticated"; deploymentExposure: "public" }, + deployment?: { + deploymentMode: "local_trusted" | "authenticated"; + deploymentExposure: "private" | "public"; + }, + useProtocolFixtureTransport = true, ) { const app = express(); app.use(express.json()); @@ -136,7 +166,16 @@ function createRouteApp( }; next(); }); - app.use("/api", toolAccessRoutes(db, { toolGateway, ...deployment })); + app.use("/api", toolAccessRoutes(db, { + toolGateway, + ...(useProtocolFixtureTransport + ? { + remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 as const }], + remoteHttpRequest: async (url: string, init: RequestInit) => fetch(url, init), + } + : {}), + ...deployment, + })); app.use(errorHandler); return app; } @@ -232,6 +271,12 @@ async function allowConnectionForAgent( connectionId: string, input: { brokerMint?: boolean } = {}, ) { + await db.insert(toolConnectionInstalls).values({ + companyId, + connectionId, + targetType: "agent", + targetId: agentId, + }); const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: `broker-${randomUUID()}`, @@ -564,6 +609,56 @@ describeEmbeddedPostgres("tool access service", () => { ])); }); + it("denies token minting with an actionable error when the requesting agent has no install", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createBrokerConnection(db, company.id); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id)); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + const res = await request(app) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) + .set("X-Paperclip-Run-Id", run.id) + .send({ scope: "pages:publish:ns/dotta" }); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ + code: "installation_required", + connection: { id: connection.id, name: connection.name }, + remediation: { action: "install_connection", targetType: "agent", targetId: agent.id }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + const [audit] = await db + .select() + .from(toolAccessAuditEvents) + .where(eq(toolAccessAuditEvents.reasonCode, "installation_required")); + expect(audit).toMatchObject({ actorType: "agent", actorId: agent.id, outcome: "failure" }); + }); + + it("accepts a company-wide install when minting a token", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createBrokerConnection(db, company.id, { path: "static" }); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + await db + .update(toolConnectionInstalls) + .set({ targetType: "company", targetId: company.id }) + .where(eq(toolConnectionInstalls.connectionId, connection.id)); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + + const res = await request(app) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) + .set("X-Paperclip-Run-Id", run.id) + .send({ scope: "pages:publish:ns/dotta" }); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ status: "use_env_lease", connectionId: connection.id }); + }); + it.each([ ["generic", undefined], ["RFC 8693", "rfc8693" as const], @@ -1261,20 +1356,30 @@ describeEmbeddedPostgres("tool access service", () => { })).rejects.toThrow("Local stdio MCP connections must use an approved templateId"); }); - it("blocks private remote HTTP endpoints in authenticated public deployments", async () => { + it.each([ + ["local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const }], + ["authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const }], + ["authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const }], + ])("always blocks link-local remote HTTP endpoints in %s before fetch", async (_label, deployment) => { const company = await createCompany(db); - const service = toolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public" }); + const service = toolAccessService(db, deployment); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should not be called")); - await expect(service.createConnection(company.id, { - name: "Metadata endpoint", - transport: "mcp_remote", - config: { url: "http://169.254.169.254/latest/meta-data" }, - enabled: true, - status: "active", - })).rejects.toMatchObject({ - status: 400, - details: { code: "remote_http_private_endpoint" }, - }); + try { + await expect(service.createConnection(company.id, { + name: "Metadata endpoint", + transport: "mcp_remote", + config: { url: "http://169.254.169.254/latest/meta-data" }, + enabled: true, + status: "active", + })).rejects.toMatchObject({ + status: 400, + details: { code: "remote_http_private_endpoint" }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } }); it("creates profiles with entries, binds them to agents, and resolves effective allowed tools", async () => { @@ -2891,6 +2996,59 @@ describeEmbeddedPostgres("tool access service", () => { ]); }); + it("serves persisted MCP actions until the cache expires and then refreshes them", async () => { + const company = await createCompany(db); + let currentTime = new Date("2026-08-20T12:00:00.000Z"); + let tools = [ + { + name: "cached_read", + description: "Read the cached value.", + annotations: { readOnlyHint: true }, + }, + ]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mcpHttpResponse({ + jsonrpc: "2.0", + id: "paperclip-catalog-refresh", + result: { tools }, + })); + const service = toolAccessService(db, { + now: () => currentTime, + catalogCacheTtlMs: 60_000, + }); + const connected = await service.connectGalleryApp(company.id, { + link: "https://cache.example.test/mcp", + name: "Cached actions", + }, { actorType: "user", actorId: "board" }); + const discoveryCallsAfterConnect = fetchMock.mock.calls.length; + + const cached = await service.listCatalog(connected.connectionId); + + expect(cached.map((entry) => entry.toolName)).toContain("cached_read"); + expect(fetchMock).toHaveBeenCalledTimes(discoveryCallsAfterConnect); + + tools = [ + ...tools, + { + name: "fresh_read", + description: "Read a newly discovered value.", + annotations: { readOnlyHint: true }, + }, + ]; + currentTime = new Date(currentTime.getTime() + 60_001); + + const refreshed = await service.listCatalog(connected.connectionId); + + expect(refreshed.map((entry) => entry.toolName)).toContain("fresh_read"); + expect(fetchMock).toHaveBeenCalledTimes(discoveryCallsAfterConnect + 1); + + currentTime = new Date(currentTime.getTime() + 60_001); + fetchMock.mockRejectedValueOnce(new Error("temporary MCP outage")); + + await expect(service.listCatalog(connected.connectionId)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ toolName: "fresh_read" })]), + ); + }); + it("requires an explicit PostHog method and projects validated project filters", async () => { const company = await createCompany(db); const service = toolAccessService(db); @@ -2945,8 +3103,8 @@ describeEmbeddedPostgres("tool access service", () => { expect(JSON.stringify(result.connection.config)).not.toContain("phx_test-secret"); expect(result.catalog).toEqual(expect.arrayContaining([ expect.objectContaining({ toolName: "query_insight", riskLevel: "read", status: "active" }), - expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "quarantined" }), - expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "quarantined" }), + expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "active" }), + expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "active" }), ])); }); @@ -3456,7 +3614,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(redirectCallbackRes.status).toBe(303); expect(redirectCallbackRes.headers.location).toBe( - `/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/setup?oauth=connected`, + `/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/test?success=1`, ); expect(fetchMock).toHaveBeenCalledTimes(6); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0); @@ -3471,7 +3629,11 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); const service = toolAccessService(db); - const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack reauth" }); + const connect = await service.connectGalleryApp( + company.id, + { galleryKey: "slack", name: "Slack reauth" }, + { actorType: "user", actorId: "operator-user" }, + ); await db .update(toolConnections) .set({ status: "active", updatedAt: new Date() }) @@ -3547,8 +3709,12 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); const service = toolAccessService(db); - const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack bound" }); const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator"); + const connect = await service.connectGalleryApp( + company.id, + { galleryKey: "slack", name: "Slack bound" }, + { actorType: "user", actorId: initiatingActor.userId }, + ); const initiatingApp = createRouteApp(db, initiatingActor); const startRes = await request(initiatingApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) @@ -5022,12 +5188,13 @@ describeEmbeddedPostgres("tool access service", () => { await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); }); - it("rejects OAuth metadata redirects to private endpoints", async () => { + it.each([ + ["local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const }], + ["authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const }], + ["authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const }], + ])("rejects OAuth metadata redirects to link-local endpoints in %s", async (_label, deployment) => { const company = await createCompany(db); - const app = createRouteApp(db, undefined, undefined, { - deploymentMode: "authenticated", - deploymentExposure: "public", - }); + const app = createRouteApp(db, undefined, undefined, deployment, false); const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if (href === "https://8.8.8.8/mcp") { @@ -5826,18 +5993,38 @@ describeEmbeddedPostgres("tool access service", () => { const service = toolAccessService(db); mockToolsList([ { name: "list_zaps", description: "List", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }, + { name: "update_zap", description: "Update", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: false } }, ]); - const connect = await service.connectGalleryApp(company.id, { - galleryKey: "zapier", - name: "Zapier reconnect", - credentialValues: { "credentials.authorization": "old-secret" }, - }, { actorType: "user", actorId: "board" }); + const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () => + service.connectGalleryApp(company.id, { + galleryKey: "zapier", + name: "Zapier reconnect", + credentialValues: { "credentials.authorization": "old-secret" }, + }, { actorType: "user", actorId: "board" })); const before = await service.getConnection(connect.connectionId, company.id); const beforeRef = before.credentialSecretRefs.find((r) => r.configPath === "credentials.authorization")!; expect(beforeRef).toBeDefined(); + const listEntry = connect.catalog.find((entry) => entry.toolName === "list_zaps")!; + const updateEntry = connect.catalog.find((entry) => entry.toolName === "update_zap")!; + const finished = await service.finishGalleryAppConnection(company.id, connect.connectionId, { + enabledCatalogEntryIds: [listEntry.id, updateEntry.id], + askFirstCatalogEntryIds: [updateEntry.id], + access: "all_agents", + }, { actorType: "user", actorId: "board" }); + await db.delete(toolProfileEntries).where(eq(toolProfileEntries.profileId, finished.profile.id)); + await db.update(toolCatalogEntries).set({ + status: "quarantined", + quarantineReason: "pending_review", + quarantinedAt: new Date(), + }).where(eq(toolCatalogEntries.connectionId, connect.connectionId)); + await db.update(toolConnections).set({ + config: { ...before.config, quarantineNewEntries: true }, + transportConfig: { ...before.transportConfig, quarantineNewEntries: true }, + }).where(eq(toolConnections.id, connect.connectionId)); + await expect( service.reconnectGalleryApp(connect.connectionId, company.id, { credentialValues: {} }, { actorType: "user", actorId: "board" }), ).rejects.toMatchObject({ message: expect.stringContaining("Paste a new key") }); @@ -5855,6 +6042,27 @@ describeEmbeddedPostgres("tool access service", () => { // Rotated in place: same secret, no duplicate ref created. expect(after.credentialSecretRefs).toHaveLength(before.credentialSecretRefs.length); expect(afterRef.secretId).toBe(beforeRef.secretId); + expect(after.config).toMatchObject({ quarantineNewEntries: false }); + expect(after.transportConfig).toMatchObject({ quarantineNewEntries: false }); + + const catalogAfterReconnect = await db.select().from(toolCatalogEntries).where( + eq(toolCatalogEntries.connectionId, connect.connectionId), + ); + expect(catalogAfterReconnect).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: listEntry.id, status: "active", quarantineReason: null }), + expect.objectContaining({ id: updateEntry.id, status: "active", quarantineReason: null }), + ])); + const profileEntriesAfterReconnect = await db.select().from(toolProfileEntries).where( + eq(toolProfileEntries.profileId, finished.profile.id), + ); + expect(profileEntriesAfterReconnect).toEqual(expect.arrayContaining([ + expect.objectContaining({ catalogEntryId: listEntry.id, effect: "include" }), + expect.objectContaining({ catalogEntryId: updateEntry.id, effect: "include" }), + ])); + await expect(db.select().from(toolPolicies).where(and( + eq(toolPolicies.companyId, company.id), + eq(toolPolicies.enabled, true), + ))).resolves.toHaveLength(0); }); it("stops and restarts local stdio runtime slots through the board service", async () => { @@ -7391,6 +7599,73 @@ describeEmbeddedPostgres("tool access service", () => { expect.objectContaining({ targetType: "agent", targetId: agent.id }), ])); }); + + it("limits connection configuration to the creator or a manager with role defaults", async () => { + const company = await createCompany(db); + const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`); + const otherMember = boardSessionActor(company.id, "member", `member-${randomUUID()}`); + const admin = boardSessionActor(company.id, "admin", `admin-${randomUUID()}`); + await grantBoardUser(db, company.id, creator.userId!, [], "member"); + await grantBoardUser(db, company.id, otherMember.userId!, [], "member"); + await grantBoardUser(db, company.id, admin.userId!, [], "admin"); + const connection = await toolAccessService(db).createConnection(company.id, { + name: "Creator-owned connection", + transport: "mcp_remote", + config: { url: PUBLIC_MCP_FIXTURE_URL }, + }, { actorType: "user", actorId: creator.userId! }); + + const denied = await request(createRouteApp(db, otherMember)) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Member edit" }); + expect(denied.status).toBe(403); + expect(denied.body.error).toContain("connection creator or a connection manager"); + + await request(createRouteApp(db, creator)) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Creator edit" }) + .expect(200); + await request(createRouteApp(db, admin)) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Admin edit" }) + .expect(200); + + const adminGrants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, admin.userId!)); + expect(adminGrants).toEqual([]); + }); + + it("keeps agent installs self-serve for members with connection access and audits changes", async () => { + const company = await createCompany(db); + const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`); + const member = boardSessionActor(company.id, "member", `member-${randomUUID()}`); + await grantBoardUser(db, company.id, creator.userId!, [], "member"); + await grantBoardUser(db, company.id, member.userId!, ["agents:configure"], "member"); + const agent = await createAgent(db, company.id); + const connection = await toolAccessService(db).createConnection(company.id, { + name: "Shared organization connection", + transport: "mcp_remote", + config: { url: PUBLIC_MCP_FIXTURE_URL }, + }, { actorType: "user", actorId: creator.userId! }); + const app = createRouteApp(db, member); + + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [{ targetType: "agent", targetId: agent.id }] }) + .expect(200); + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [] }) + .expect(200); + + const audits = await db + .select() + .from(toolAccessAuditEvents) + .where(eq(toolAccessAuditEvents.action, "connection_installs.changed")); + expect(audits).toHaveLength(2); + expect(audits.every((audit) => audit.actorType === "user" && audit.actorId === member.userId)).toBe(true); + }); }); describe("classifyRisk", () => { diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 01d953032a..9aa3d1733c 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -91,7 +91,9 @@ async function createRemoteMcpToolFixture(db: ReturnType, compa status: "active", enabled: true, healthStatus: "ok", - config: { url: "https://example.invalid/mcp" }, + // Use a public IP literal so protocol tests remain independent of DNS while + // still exercising the production egress guard and their global fetch stub. + config: { url: "https://8.8.8.8/mcp" }, }).returning().then((rows) => rows[0]!); const catalogEntry = await db.insert(toolCatalogEntries).values({ companyId, diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index ca427fc623..8a9b8c299d 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -1405,7 +1405,11 @@ rl.on("line", (line) => { ])); }); - it("blocks private remote HTTP endpoints in authenticated public deployments before dispatch", async () => { + it.each([ + ["local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const }], + ["authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const }], + ["authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const }], + ])("always blocks link-local gateway dispatch in %s before fetch", async (_label, deployment) => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); @@ -1417,10 +1421,7 @@ rl.on("line", (line) => { await allowAllToolsForAgent(db, company.id, agent.id); const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should not be called")); try { - const gateway = createTestToolGatewayService(db, { - deploymentMode: "authenticated", - deploymentExposure: "public", - }); + const gateway = createTestToolGatewayService(db, deployment); const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); const connectedTool = (await gateway.listToolsForSession(session.token)) .find((tool) => tool.providerType === "mcp_remote_http"); @@ -3320,7 +3321,7 @@ rl.on("line", (line) => { expect(audit.body).toHaveProperty("nextCursor"); }); - it("filters, paginates, and enriches tool gateway audit events server-side", async () => { + it("aggregates connection activity with server-side filters, pagination, and enrichment", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const otherAgent = await createAgent(db, company.id); @@ -3339,6 +3340,25 @@ rl.on("line", (line) => { status: "active", enabled: true, }).returning(); + const [profile] = await db.insert(toolProfiles).values({ + companyId: company.id, + profileKey: `audit-${randomUUID()}`, + name: `Audit ${randomUUID()}`, + }).returning(); + const [gateway, otherGateway] = await db.insert(toolMcpGateways).values([ + { + companyId: company.id, + name: `Audit gateway ${randomUUID()}`, + slug: `audit-${randomUUID()}`, + profileId: profile!.id, + }, + { + companyId: company.id, + name: `Other gateway ${randomUUID()}`, + slug: `other-${randomUUID()}`, + profileId: profile!.id, + }, + ]).returning(); const [newerInvocation, olderInvocation, otherInvocation] = await db.insert(toolInvocations).values([ { companyId: company.id, @@ -3346,9 +3366,16 @@ rl.on("line", (line) => { actorId: agent.id, agentId: agent.id, runId: run.id, + gatewayId: gateway!.id, applicationId: application!.id, connectionId: connection!.id, toolName: "mail:send_email", + argumentsSummary: { summary: JSON.stringify({ to: "person@example.test", token: "***REDACTED***" }) }, + resultSummary: { summary: JSON.stringify({ delivered: true }) }, + policyDecision: "allow", + status: "succeeded", + startedAt: new Date(Date.now() - 1_500), + completedAt: new Date(Date.now() - 1_000), }, { companyId: company.id, @@ -3356,6 +3383,7 @@ rl.on("line", (line) => { actorId: agent.id, agentId: agent.id, runId: run.id, + gatewayId: gateway!.id, applicationId: application!.id, connectionId: connection!.id, toolName: "mail:read_email", @@ -3366,48 +3394,77 @@ rl.on("line", (line) => { actorId: otherAgent.id, agentId: otherAgent.id, runId: run.id, + gatewayId: otherGateway!.id, + applicationId: application!.id, + connectionId: connection!.id, toolName: "other:delete_everything", }, ]).returning(); const now = Date.now(); - await db.insert(activityLog).values([ + const callEvents = await db.insert(toolCallEvents).values([ { companyId: company.id, + eventType: "call_completed", actorType: "agent", actorId: agent.id, - action: "tool_gateway.call_completed", - entityType: "issue", - entityId: run.id, agentId: agent.id, runId: run.id, - details: { invocationId: newerInvocation!.id, decision: "allow", reasonCode: "tool_completed", tool: "mail:send_email", upstreamToolName: "fixture.todo.list" }, + gatewayId: gateway!.id, + applicationId: application!.id, + connectionId: connection!.id, + invocationId: newerInvocation!.id, + toolName: "mail:send_email", + decision: "allow", + reasonCode: "tool_completed", + outcome: "success", + metadata: { upstreamToolName: "fixture.todo.list" }, createdAt: new Date(now - 1_000), }, { companyId: company.id, + eventType: "call_completed", actorType: "agent", actorId: agent.id, - action: "tool_gateway.call_allowed", - entityType: "issue", - entityId: run.id, agentId: agent.id, runId: run.id, - details: { invocationId: olderInvocation!.id, decision: "allow", reasonCode: "profile_allows_tool", tool: "mail:read_email" }, + gatewayId: gateway!.id, + applicationId: application!.id, + connectionId: connection!.id, + invocationId: olderInvocation!.id, + toolName: "mail:read_email", + decision: "allow", + reasonCode: "profile_allows_tool", + outcome: "success", createdAt: new Date(now - 2_000), }, { companyId: company.id, + eventType: "call_denied", actorType: "agent", actorId: otherAgent.id, - action: "tool_gateway.call_denied", - entityType: "issue", - entityId: run.id, agentId: otherAgent.id, runId: run.id, - details: { invocationId: otherInvocation!.id, decision: "deny", reasonCode: "deny_policy_block", tool: "other:delete_everything" }, + gatewayId: otherGateway!.id, + applicationId: application!.id, + connectionId: connection!.id, + invocationId: otherInvocation!.id, + toolName: "other:delete_everything", + decision: "deny", + reasonCode: "deny_policy_block", + outcome: "denied", createdAt: new Date(now - 500), }, - ]); + ]).returning(); + const [connectedEvent] = await db.insert(activityLog).values({ + companyId: company.id, + actorType: "system", + actorId: "system", + action: "tool_app.connected", + entityType: "tool_connection", + entityId: connection!.id, + details: { galleryKey: "mail" }, + createdAt: new Date(now - 45 * 24 * 60 * 60 * 1000), + }).returning(); const app = createGatewayRouteApp(db, createTestToolGatewayService(db), { type: "board", @@ -3418,9 +3475,27 @@ rl.on("line", (line) => { isInstanceAdmin: true, }); + const allActivity = await request(app) + .get("/api/tool-gateway/audit") + .query({ companyId: company.id }); + expect(allActivity.status).toBe(200); + expect(allActivity.body.events.map((event: { id: string }) => event.id)).toEqual([ + callEvents[2]!.id, + callEvents[0]!.id, + callEvents[1]!.id, + connectedEvent!.id, + ]); + expect(allActivity.body.events.find((event: { id: string }) => event.id === connectedEvent!.id)).toMatchObject({ + action: "tool_connection.app_connected", + connectionId: connection!.id, + applicationId: application!.id, + appDisplayName: "Mail", + lifecycleType: "app_connected", + }); + const firstPage = await request(app) .get("/api/tool-gateway/audit") - .query({ companyId: company.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 }); + .query({ companyId: company.id, gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 }); expect(firstPage.status).toBe(200); expect(firstPage.body.events).toEqual([ expect.objectContaining({ @@ -3432,6 +3507,14 @@ rl.on("line", (line) => { appDisplayName: "Mail", toolDisplayName: "Send Email", normalizedOutcome: "allowed", + invocation: expect.objectContaining({ + id: newerInvocation!.id, + toolName: "mail:send_email", + status: "succeeded", + policyDecision: "allow", + argumentsSummary: expect.objectContaining({ summary: expect.stringContaining("***REDACTED***") }), + resultSummary: expect.objectContaining({ summary: expect.stringContaining("delivered") }), + }), }), ]); expect(typeof firstPage.body.nextCursor).toBe("string"); @@ -3440,6 +3523,7 @@ rl.on("line", (line) => { .get("/api/tool-gateway/audit") .query({ companyId: company.id, + gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", @@ -3450,7 +3534,7 @@ rl.on("line", (line) => { expect(secondPage.status).toBe(200); expect(secondPage.body.events).toEqual([ expect.objectContaining({ - action: "tool_gateway.call_allowed", + action: "tool_gateway.call_completed", toolDisplayName: "Read Email", }), ]); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 3693fccf95..e829ceed65 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -38,6 +38,8 @@ import { releaseRuntimeServicesForRun, UnresolvedWorkspaceBaseRefError, resetRuntimeServicesForTests, + MANAGED_RUNTIME_PUBLIC_URL_ENV, + resolveManagedPaperclipRuntimePublicOrigin, resolveRuntimeProvisionCommand, resolveWorkspaceRuntimeReadinessTimeoutSec, resolveShell, @@ -456,6 +458,8 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { DATABASE_URL: "postgres://example.test/paperclip", PAPERCLIP_HOME: "/tmp/paperclip-home", PAPERCLIP_INSTANCE_ID: "runtime-instance", + BETTER_AUTH_URL: "https://parent.example.test", + BETTER_AUTH_BASE_URL: "https://legacy-parent.example.test", npm_config_tailscale_auth: "true", npm_config_authenticated_private: "true", HOST: "0.0.0.0", @@ -463,6 +467,8 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { expect(sanitized.PAPERCLIP_HOME).toBeUndefined(); expect(sanitized.PAPERCLIP_INSTANCE_ID).toBeUndefined(); + expect(sanitized.BETTER_AUTH_URL).toBeUndefined(); + expect(sanitized.BETTER_AUTH_BASE_URL).toBeUndefined(); expect(sanitized.DATABASE_URL).toBeUndefined(); expect(sanitized.npm_config_tailscale_auth).toBeUndefined(); expect(sanitized.npm_config_authenticated_private).toBeUndefined(); @@ -470,6 +476,70 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { }); }); +describe("resolveManagedPaperclipRuntimePublicOrigin", () => { + const baseInput = { + serviceName: "paperclip-dev", + command: "pnpm dev --bind lan", + }; + + it("leaves explicit operator origin configuration unchanged", () => { + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: { PAPERCLIP_PUBLIC_URL: "https://operator.example.com" }, + exposedUrl: "https://managed-worktree.example.com", + })).toBeNull(); + + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: { BETTER_AUTH_URL: "https://auth.example.com" }, + exposedUrl: "https://managed-worktree.example.com", + })).toBeNull(); + }); + + it("infers browser-reachable HTTPS and loopback origins", () => { + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "https://paperclip-dev.tail29c1aa.ts.net/path?ignored=true", + exposedUrlTemplate: "https://{{workspace.branchName}}.tail29c1aa.ts.net", + })).toBe("https://paperclip-dev.tail29c1aa.ts.net"); + expect(resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "http://127.0.0.1:45439", + })).toBe("http://127.0.0.1:45439"); + }); + + it("rejects internal-only and unsafe inferred origins with actionable guidance", () => { + expect(() => resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "http://paperclip-dev:45439", + })).toThrow(/internal-only.*Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL/); + expect(() => resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "http://10.0.0.8:45439", + })).toThrow(/non-loopback OAuth callbacks require HTTPS/); + }); + + it("keeps interpolated hostnames inside the operator-configured domain", () => { + expect(() => resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "https://evil.com/workaround.tail29c1aa.ts.net", + exposedUrlTemplate: "https://{{workspace.branchName}}.tail29c1aa.ts.net", + })).toThrow(/outside the hostname boundary configured by expose\.urlTemplate/); + + expect(() => resolveManagedPaperclipRuntimePublicOrigin({ + ...baseInput, + environment: {}, + exposedUrl: "https://managed-worktree.paperclip.dev", + exposedUrlTemplate: "https://{{workspace.branchName}}.com", + })).toThrow(/does not define a stable hostname boundary/); + }); +}); + describe("resolveRuntimeProvisionCommand", () => { it("backfills deferred seeding for legacy managed git worktrees", async () => { const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-")); @@ -4274,6 +4344,32 @@ describe("ensureRuntimeServicesForRun", () => { } }); + it("preserves the selected persisted runtime id when starting one configured service", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-selected-id-")); + const workspace = buildWorkspace(workspaceRoot); + const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "runtime-selected-id"); + const runtimeServiceId = randomUUID(); + const config = runtimeProvisionTestConfig({}); + + try { + const services = await startRuntimeServicesForWorkspaceControl({ + ...runtimeProvisionStartInput({ workspace, config }), + runtimeServiceId, + serviceIndex: 0, + }); + + expect(services).toHaveLength(1); + expect(services[0]?.id).toBe(runtimeServiceId); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + restorePaperclipEnv(); + } + }); + it("leaves manual runtime services untouched during agent runs", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-manual-")); const workspace = buildWorkspace(workspaceRoot); @@ -4305,6 +4401,93 @@ describe("ensureRuntimeServicesForRun", () => { expect(services).toEqual([]); }); + it("injects isolated browser callback origins into separate worktree runtimes", async () => { + const firstRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-origin-first-")); + const secondRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-origin-second-")); + const firstWorkspace: RealizedExecutionWorkspace = { + ...buildWorkspace(firstRoot), + source: "task_session", + strategy: "git_worktree", + branchName: "pap-17121-first", + worktreePath: firstRoot, + }; + const secondWorkspace: RealizedExecutionWorkspace = { + ...buildWorkspace(secondRoot), + source: "task_session", + strategy: "git_worktree", + branchName: "pap-17121-second", + worktreePath: secondRoot, + }; + const serviceScript = + "const http=require('node:http');" + + "http.createServer((req,res)=>{" + + "if(req.url==='/api/health'){res.setHeader('content-type','application/json');" + + "res.end(JSON.stringify({status:'ok'}));return;}" + + `res.end(process.env.${MANAGED_RUNTIME_PUBLIC_URL_ENV}||'missing');` + + "}).listen(Number(process.env.PORT),'127.0.0.1');"; + const config = { + workspaceRuntime: { + services: [ + { + name: "paperclip-dev", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serviceScript)}`, + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "https://{{workspace.branchName}}.tail29c1aa.ts.net", + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { type: "manual" }, + }, + ], + }, + }; + const actor = { id: "agent-1", name: "Codex Coder", companyId: "company-1" }; + + try { + const [first] = await startRuntimeServicesForWorkspaceControl({ + actor, + issue: null, + workspace: firstWorkspace, + executionWorkspaceId: "execution-workspace-first", + config, + adapterEnv: {}, + }); + const [second] = await startRuntimeServicesForWorkspaceControl({ + actor, + issue: null, + workspace: secondWorkspace, + executionWorkspaceId: "execution-workspace-second", + config, + adapterEnv: {}, + }); + + expect(first?.id).not.toBe(second?.id); + await expect(fetch(`http://127.0.0.1:${first!.port}/origin`).then((response) => response.text())) + .resolves.toBe("https://pap-17121-first.tail29c1aa.ts.net"); + await expect(fetch(`http://127.0.0.1:${second!.port}/origin`).then((response) => response.text())) + .resolves.toBe("https://pap-17121-second.tail29c1aa.ts.net"); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-first", + workspaceCwd: firstRoot, + }); + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-second", + workspaceCwd: secondRoot, + }); + await fs.rm(firstRoot, { recursive: true, force: true }); + await fs.rm(secondRoot, { recursive: true, force: true }); + } + }, 15_000); + it("requires Paperclip dev runtime services to pass /api/health readiness", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-health-")); const workspace = buildWorkspace(workspaceRoot); @@ -4441,7 +4624,7 @@ describe("ensureRuntimeServicesForRun", () => { } }); - it("uses explicit readiness URL when exposed URL is not the local probe address", async () => { + it("rejects an unreachable exposed origin even when readiness uses a local probe", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-explicit-readiness-")); const workspace = buildWorkspace(workspaceRoot); const runId = "run-paperclip-explicit-readiness"; @@ -4449,7 +4632,7 @@ describe("ensureRuntimeServicesForRun", () => { "node -e \"const http=require('node:http'); http.createServer((req,res)=>{ if (req.url==='/api/health') { res.end('ok'); return; } res.statusCode=404; res.end('not found'); }).listen(Number(process.env.PORT), '127.0.0.1')\""; try { - const services = await ensureRuntimeServicesForRun({ + await expect(ensureRuntimeServicesForRun({ runId, agent: { id: "agent-1", @@ -4485,10 +4668,7 @@ describe("ensureRuntimeServicesForRun", () => { }, }, adapterEnv: {}, - }); - - expect(services).toHaveLength(1); - expect(services[0]?.url).toMatch(/^http:\/\/not-a-real-paperclip-host\.invalid:\d+$/); + })).rejects.toThrow(/internal-only or non-resolvable.*Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL/); } finally { await releaseRuntimeServicesForRun(runId); } diff --git a/server/src/app.ts b/server/src/app.ts index e7d4230ca8..c06af3630a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -577,6 +577,7 @@ export async function createApp( api.use(toolAccessRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, + authPublicBaseUrl: opts.authPublicBaseUrl, trustedLocalStdioRuntimeHost, toolGateway, })); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index c367ffd3ac..6fd400a6ec 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -92,11 +92,21 @@ export function shouldDisableSecureAuthCookies(input: { authBaseUrlMode: Config["authBaseUrlMode"]; authPublicBaseUrl: string | undefined; publicUrl?: string | undefined; + managedRuntimePublicUrl?: string | undefined; + requestUrl?: string | undefined; }): boolean { const publicUrl = ( input.publicUrl?.trim() || (input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl?.trim() : "") ); + if ( + input.deploymentMode === "authenticated" && + isHttpsUrl(publicUrl) && + isHttpsUrl(input.managedRuntimePublicUrl) && + isHttpLoopbackUrl(input.requestUrl) + ) { + return true; + } if (publicUrl) return publicUrl.startsWith("http://"); return ( @@ -108,6 +118,52 @@ export function shouldDisableSecureAuthCookies(input: { ); } +function isHttpsUrl(value: string | undefined): boolean { + if (!value) return false; + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "[::1]" || + normalized === "::1" + ); +} + +function isHttpLoopbackUrl(value: string | undefined): boolean { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === "http:" && isLoopbackHostname(url.hostname); + } catch { + return false; + } +} + +function requestUrlFromHeaders(headers: Headers): string | undefined { + const host = headers.get("host")?.trim(); + if (!host) return undefined; + + const forwardedProtocol = headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim().toLowerCase(); + const protocol = forwardedProtocol === "http" || forwardedProtocol === "https" + ? forwardedProtocol + : (() => { + try { + return isLoopbackHostname(new URL(`http://${host}`).hostname) ? "http" : "https"; + } catch { + return "https"; + } + })(); + return `${protocol}://${host}`; +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -185,6 +241,7 @@ export function resolveWorkspaceHandoffIdentity( export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl; + const managedRuntimePublicUrl = process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim() || undefined; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET; if (!secret) { throw new Error( @@ -252,7 +309,48 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: delete (authConfig as { baseURL?: string }).baseURL; } - return betterAuth(authConfig); + const defaultAuth = betterAuth(authConfig); + const supportsManagedLoopbackAuth = Boolean( + !disableSecureCookies && + isHttpsUrl(publicUrl) && + isHttpsUrl(managedRuntimePublicUrl), + ); + if (!supportsManagedLoopbackAuth) return defaultAuth; + + // Better Auth fixes both the Secure attribute and the __Secure- name prefix + // when an instance is created. Keep the public instance unchanged and route + // only managed HTTP-loopback requests through a cookie-compatible instance. + const loopbackAuth = betterAuth({ + ...authConfig, + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }), + }); + const cookieSecurityInput = { + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl, + publicUrl, + managedRuntimePublicUrl, + }; + + return { + handler: (request) => { + const auth = shouldDisableSecureAuthCookies({ + ...cookieSecurityInput, + requestUrl: request.url, + }) ? loopbackAuth : defaultAuth; + return auth.handler(request); + }, + api: { + getSession: (input) => { + const auth = shouldDisableSecureAuthCookies({ + ...cookieSecurityInput, + requestUrl: requestUrlFromHeaders(input.headers), + }) ? loopbackAuth : defaultAuth; + return auth.api.getSession(input); + }, + }, + }; } export function createBetterAuthHandler(auth: BetterAuthHandlerTarget): RequestHandler { diff --git a/server/src/config.ts b/server/src/config.ts index 332b9d9c89..996b6e3cfb 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -198,17 +198,21 @@ export function loadConfig(): Config { ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) : null; const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL; - const authPublicBaseUrlRaw = - process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? - process.env.BETTER_AUTH_URL ?? - process.env.BETTER_AUTH_BASE_URL ?? - publicUrlFromEnv ?? - fileConfig?.auth?.publicBaseUrl; + const configuredAuthPublicBaseUrlRaw = [ + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL, + process.env.BETTER_AUTH_URL, + process.env.BETTER_AUTH_BASE_URL, + publicUrlFromEnv, + fileConfig?.auth?.publicBaseUrl, + ].find((value): value is string => typeof value === "string" && value.trim().length > 0); + const managedRuntimePublicUrl = process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim() || undefined; + const authPublicBaseUrlRaw = configuredAuthPublicBaseUrlRaw ?? managedRuntimePublicUrl; const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined; const authBaseUrlMode: AuthBaseUrlMode = authBaseUrlModeFromEnv ?? - fileConfig?.auth?.baseUrlMode ?? - (authPublicBaseUrl ? "explicit" : "auto"); + (configuredAuthPublicBaseUrlRaw === undefined && managedRuntimePublicUrl + ? "explicit" + : fileConfig?.auth?.baseUrlMode ?? (authPublicBaseUrl ? "explicit" : "auto")); const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP; const authDisableSignUp: boolean = disableSignUpFromEnv !== undefined diff --git a/server/src/index.ts b/server/src/index.ts index 2b7816eb18..09a66a0d91 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -587,7 +587,10 @@ export async function startServer(): Promise { } const requestedListenPort = config.port; - const listenPort = await detectPort(requestedListenPort); + const listenPort = await detectPort({ + port: requestedListenPort, + hostname: config.host, + }); if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) { config.authPublicBaseUrl = rewriteLoopbackUrlPort(config.authPublicBaseUrl, listenPort); } diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index cd5e67d3e1..f3147df632 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -895,6 +895,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P onLog, recorder, serviceIndex: selectedServiceIndex, + runtimeServiceId: selectedRuntimeServiceId, }); } catch (error) { // A failed start must leave the workspace stopped and retryable rather than diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 57518c80b5..6f58cad51c 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -579,6 +579,7 @@ export function projectRoutes(db: Db) { adapterEnv: {}, onLog, serviceIndex: selectedServiceIndex, + runtimeServiceId: selectedRuntimeServiceId, }); runtimeServiceCount = startedServices.length; } else { diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index f55f3c9048..02d6252407 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1,7 +1,7 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; -import { agents, companies } from "@paperclipai/db"; -import { eq } from "drizzle-orm"; +import { agents, companies, connectionGrants, toolConnectionInstalls } from "@paperclipai/db"; +import { and, eq, or } from "drizzle-orm"; import { CONNECTABLE_APP_DEFINITIONS, DEFAULT_OWNERSHIP_AVAILABILITY, @@ -109,8 +109,12 @@ export function toolAccessRoutes( options: { deploymentMode?: DeploymentMode; deploymentExposure?: DeploymentExposure; + authPublicBaseUrl?: string | null; trustedLocalStdioRuntimeHost?: string | null; toolGateway?: ToolGatewayService; + /** Test-only seams forwarded to the tool access service. */ + remoteHttpEndpointLookup?: NonNullable[1]>["remoteHttpEndpointLookup"]; + remoteHttpRequest?: NonNullable[1]>["remoteHttpRequest"]; } = {}, ) { const router = Router(); @@ -123,6 +127,8 @@ export function toolAccessRoutes( || process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || process.env.BETTER_AUTH_URL?.trim() || process.env.BETTER_AUTH_BASE_URL?.trim() + || options.authPublicBaseUrl?.trim() + || process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim() ); if (!raw) return null; try { @@ -143,14 +149,18 @@ export function toolAccessRoutes( return new URL("/api/tools/oauth/callback", configured).toString(); } - async function oauthSetupPath(companyId: string, connectionId: string) { + async function oauthAppPath( + companyId: string, + connectionId: string, + tab: "setup" | "test", + ) { const [company] = await db .select({ issuePrefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, companyId)) .limit(1); if (!company) throw new Error("OAuth callback connection belongs to a missing company"); - return `/${company.issuePrefix}/apps/${connectionId}/setup`; + return `/${company.issuePrefix}/apps/${connectionId}/${tab}`; } const access = accessService(db); @@ -163,6 +173,71 @@ export function toolAccessRoutes( throw forbidden(`Missing permission: ${permissionKey}`); } + function activeToolMembership(req: Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; + const membership = Array.isArray(req.actor.memberships) + ? req.actor.memberships.find((item) => item.companyId === companyId) + : null; + if (!membership || membership.status !== "active") { + throw forbidden("User does not have active company access"); + } + if (!membership.membershipRole || membership.membershipRole === "viewer") { + throw forbidden("Viewer access is read-only"); + } + return membership; + } + + async function isToolConnectionManager(req: Request, companyId: string) { + const membership = activeToolMembership(req, companyId); + if (!membership) return true; + if (membership.membershipRole === "owner" || membership.membershipRole === "admin") return true; + return Boolean(req.actor.userId && await access.hasPermission( + companyId, + "user", + req.actor.userId, + "tools:manage_connections", + )); + } + + async function assertToolConnectionConfigureAccess( + req: Request, + connection: { companyId: string; createdByUserId?: string | null }, + ) { + if (await isToolConnectionManager(req, connection.companyId)) return; + if (req.actor.userId && connection.createdByUserId === req.actor.userId) return; + throw forbidden( + "Only the connection creator or a connection manager can configure, reconnect, or delete this connection", + ); + } + + async function assertToolConnectionAccess( + req: Request, + connection: { id: string; companyId: string; createdByUserId?: string | null }, + ) { + activeToolMembership(req, connection.companyId); + if (await isToolConnectionManager(req, connection.companyId)) return; + if (req.actor.userId && connection.createdByUserId === req.actor.userId) return; + const [grant] = await db + .select({ id: connectionGrants.id }) + .from(connectionGrants) + .where(and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.status, "active"), + or( + eq(connectionGrants.kind, "workspace"), + req.actor.userId + ? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId)) + : eq(connectionGrants.kind, "workspace"), + ), + )) + .limit(1); + if (grant) return; + throw forbidden("You need access to this connection before you can install it on an agent"); + } + async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) { assertBoard(req); assertCompanyAccess(req, companyId); @@ -257,18 +332,7 @@ export function toolAccessRoutes( }); function assertToolAppMutationAccess(req: Request, companyId: string) { - assertBoard(req); - assertCompanyAccess(req, companyId); - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const membership = Array.isArray(req.actor.memberships) - ? req.actor.memberships.find((item) => item.companyId === companyId) - : null; - if (!membership || membership.status !== "active") { - throw forbidden("User does not have active company access"); - } - if (!membership.membershipRole || membership.membershipRole === "viewer") { - throw forbidden("Viewer access is read-only"); - } + activeToolMembership(req, companyId); } router.get("/companies/:companyId/tools/gallery", async (req, res) => { @@ -361,11 +425,12 @@ export function toolAccessRoutes( validate(startConnectionAuthorizationSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertToolAppMutationAccess(req, companyId); + activeToolMembership(req, companyId); if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) { throw forbidden("Board users may only authorize their own connection subject"); } const existing = await svc.getConnection(req.params.connectionId as string, companyId); + await assertToolConnectionAccess(req, existing); const result = await svc.startOAuth(companyId, existing.id, { redirectUri: oauthRedirectUri(), actor: getActorInfo(req), @@ -380,7 +445,7 @@ export function toolAccessRoutes( router.post("/tools/oauth/:connectionId/start", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const result = await svc.startOAuth(existing.companyId, existing.id, { redirectUri: oauthRedirectUri(), actor: getActorInfo(req), @@ -401,7 +466,12 @@ export function toolAccessRoutes( if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) { throw badRequest("Invalid or expired OAuth state"); } - assertToolAppMutationAccess(req, pendingState.companyId); + const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId); + if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) { + await assertToolConnectionAccess(req, pendingConnection); + } else { + await assertToolConnectionConfigureAccess(req, pendingConnection); + } const acceptsHtml = req.get("accept")?.includes("text/html") === true; let result: Awaited>; try { @@ -429,7 +499,7 @@ export function toolAccessRoutes( oauth: callbackErrorCode === "oauth_authorization_denied" ? "denied" : "failed", }); if (callbackErrorCode) params.set("code", callbackErrorCode); - const setupPath = await oauthSetupPath(pendingState.companyId, pendingState.connectionId); + const setupPath = await oauthAppPath(pendingState.companyId, pendingState.connectionId, "setup"); res.redirect(303, `${setupPath}?${params.toString()}`); return; } @@ -446,8 +516,8 @@ export function toolAccessRoutes( }, }); if (acceptsHtml) { - const setupPath = await oauthSetupPath(result.connection.companyId, result.connection.id); - res.redirect(303, `${setupPath}?oauth=connected`); + const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test"); + res.redirect(303, `${testPath}?success=1`); return; } res.json(result); @@ -455,8 +525,8 @@ export function toolAccessRoutes( router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertToolAppMutationAccess(req, companyId); const existing = await svc.getConnection(req.params.connectionId as string, companyId); + await assertToolConnectionConfigureAccess(req, existing); const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, getActorInfo(req)); await logActivity(db, { companyId, @@ -629,7 +699,7 @@ export function toolAccessRoutes( const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { - const connection = await svc.createConnection(companyId, req.body); + const connection = await svc.createConnection(companyId, req.body, getActorInfo(req)); await logActivity(db, { companyId, actorType: "user", @@ -668,7 +738,7 @@ export function toolAccessRoutes( assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + await assertToolConnectionConfigureAccess(req, connection); const body = req.body && typeof req.body === "object" ? req.body as Record : {}; const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : []; const providerTenant = body.providerTenant && typeof body.providerTenant === "object" @@ -695,7 +765,14 @@ export function toolAccessRoutes( assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId); + const grantToRevoke = grants.find((grant) => grant.id === req.params.grantId); + const canRevokeOwnGrant = Boolean( + req.actor.userId + && grantToRevoke + && (grantToRevoke.subjectUserId === req.actor.userId || grantToRevoke.createdByUserId === req.actor.userId), + ); + if (!canRevokeOwnGrant) await assertToolConnectionConfigureAccess(req, connection); const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req)); await logActivity(db, { companyId: connection.companyId, @@ -732,7 +809,47 @@ export function toolAccessRoutes( assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + const existingInstalls = await db + .select() + .from(toolConnectionInstalls) + .where(and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + )); + const requestedInstalls = req.body.installs as Array<{ targetType: "company" | "agent"; targetId: string }>; + const requestedKeys = new Set(requestedInstalls.map((install) => `${install.targetType}:${install.targetId}`)); + const existingKeys = new Set(existingInstalls.map((install) => `${install.targetType}:${install.targetId}`)); + const changedInstalls = [ + ...requestedInstalls.filter((install) => !existingKeys.has(`${install.targetType}:${install.targetId}`)), + ...existingInstalls.filter((install) => !requestedKeys.has(`${install.targetType}:${install.targetId}`)), + ]; + if (changedInstalls.some((install) => install.targetType === "company")) { + await assertToolConnectionConfigureAccess(req, connection); + } + if (changedInstalls.some((install) => install.targetType === "agent")) { + await assertToolConnectionAccess(req, connection); + const changedAgentIds = [...new Set( + changedInstalls + .filter((install) => install.targetType === "agent") + .map((install) => install.targetId), + )]; + for (const agentId of changedAgentIds) { + const [agent] = await db + .select({ id: agents.id, companyId: agents.companyId }) + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, connection.companyId))) + .limit(1); + if (!agent) throw forbidden("The target agent is not available in this company"); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: connection.companyId, agentId: agent.id }, + }); + if (!decision.allowed) { + throw forbidden(`You cannot edit agent ${agent.id}, so you cannot change its connection installs`); + } + } + } const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req)); await logActivity(db, { companyId: connection.companyId, @@ -844,7 +961,7 @@ export function toolAccessRoutes( router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const connection = await svc.updateConnection(existing.id, req.body); const lifecycleChanges = classifyConnectionUpdate( { enabled: existing.enabled, config: existing.config }, @@ -888,7 +1005,7 @@ export function toolAccessRoutes( router.delete("/tool-connections/:connectionId", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const applicationBefore = await svc.getApplication(existing.applicationId); const { connection, removal } = await svc.archiveConnection( existing.id, @@ -925,7 +1042,7 @@ export function toolAccessRoutes( router.post("/tool-connections/:connectionId/health-check", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); res.json(await svc.checkHealth(existing.id, getActorInfo(req))); }); @@ -935,7 +1052,7 @@ export function toolAccessRoutes( async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const result = await svc.reconnectGalleryApp( existing.id, existing.companyId, @@ -958,7 +1075,7 @@ export function toolAccessRoutes( router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); res.json(await svc.refreshCatalog(existing.id, getActorInfo(req))); }); diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index 76a91c54a3..c45ac990da 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -1,8 +1,12 @@ import { Router, type Request, type Response } from "express"; import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, toolApplications, toolConnections, toolInvocations } from "@paperclipai/db"; -import { humanizeConnectionDisplayName, type PermissionKey } from "@paperclipai/shared"; +import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db"; +import { + humanizeConnectionDisplayName, + type PermissionKey, + type ToolConnectionLifecycleEventType, +} from "@paperclipai/shared"; import { createToolMcpGatewaySchema, createToolMcpGatewayTokenSchema, @@ -12,26 +16,22 @@ import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } fr import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; import { forbidden, HttpError } from "../errors.js"; import { accessService } from "../services/index.js"; +import { listConnectionLifecycleEvents } from "../services/tool-connection-activity.js"; -const TOOL_GATEWAY_ACTIONS = [ - "tool_gateway.session_created", - "tool_gateway.session_revoked", - "tool_gateway.session_rejected", - "tool_gateway.discovery", - "tool_gateway.call_allowed", - "tool_gateway.call_denied", - "tool_gateway.call_completed", - "tool_gateway.call_failed", - "tool_gateway.call_deferred", - "tool_gateway.approval_requested", - "tool_gateway.runtime_mcp_delivery", -]; +const TOOL_ACTIVITY_EVENT_TYPES = [ + "call_completed", + "call_failed", + "call_denied", + "approval_requested", + "approval_resolved", +] as const; -const TOOL_GATEWAY_WINDOWS: Record = { +const TOOL_GATEWAY_WINDOWS: Record = { "1h": 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, + all: null, }; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -194,42 +194,53 @@ function decodeAuditCursor(value: string): { createdAt: Date; id: string } | nul } } -function normalizedAuditOutcome(action: string, details: Record | null | undefined) { - const decision = detailString(details, "decision"); - if (action === "tool_gateway.call_completed" || action === "tool_gateway.call_allowed" || decision === "allow" || decision === "approved") return "allowed"; - if (action === "tool_gateway.approval_requested" || decision === "require_approval") return "asked_first"; - if (action === "tool_gateway.call_deferred" || decision === "defer_runtime") return "waiting"; - if (action === "tool_gateway.call_failed") return "failed"; - if (action === "tool_gateway.call_denied" || decision === "deny" || decision === "rate_limited") return "blocked"; +function toolActivityAction(eventType: string): string { + return `tool_gateway.${eventType}`; +} + +function normalizedAuditOutcome( + eventType: string, + outcome: string, + decision: string | null, +) { + if (eventType === "approval_requested") return "asked_first"; + if (decision === "defer_runtime") return "waiting"; + if (eventType === "call_denied" || outcome === "denied" || decision === "deny") return "blocked"; + if (eventType === "call_failed" || ["failure", "timeout", "cancelled"].includes(outcome)) return "failed"; + if (eventType === "call_completed" || eventType === "approval_resolved" || outcome === "success" || decision === "allow") return "allowed"; return "unknown"; } function outcomeCondition(outcome: string) { if (outcome === "allowed") { return or( - inArray(activityLog.action, ["tool_gateway.call_allowed", "tool_gateway.call_completed"]), - sql`${activityLog.details}->>'decision' in ('allow', 'approved')`, + eq(toolCallEvents.eventType, "call_completed"), + and(eq(toolCallEvents.eventType, "approval_resolved"), eq(toolCallEvents.outcome, "success")), + eq(toolCallEvents.decision, "allow"), ); } if (outcome === "blocked" || outcome === "denied") { return or( - eq(activityLog.action, "tool_gateway.call_denied"), - sql`${activityLog.details}->>'decision' in ('deny', 'rate_limited')`, + eq(toolCallEvents.eventType, "call_denied"), + eq(toolCallEvents.outcome, "denied"), + eq(toolCallEvents.decision, "deny"), ); } if (outcome === "asked_first" || outcome === "approval") { - return or( - eq(activityLog.action, "tool_gateway.approval_requested"), - sql`${activityLog.details}->>'decision' = 'require_approval'`, - ); + return eq(toolCallEvents.eventType, "approval_requested"); } if (outcome === "waiting" || outcome === "deferred") { - return or( - eq(activityLog.action, "tool_gateway.call_deferred"), - sql`${activityLog.details}->>'decision' = 'defer_runtime'`, + return eq(toolCallEvents.decision, "defer_runtime"); + } + if (outcome === "failed") { + return and( + or( + eq(toolCallEvents.eventType, "call_failed"), + inArray(toolCallEvents.outcome, ["failure", "timeout", "cancelled"]), + ), + sql`${toolCallEvents.decision} is distinct from 'defer_runtime'`, ); } - if (outcome === "failed") return eq(activityLog.action, "tool_gateway.call_failed"); return null; } @@ -644,12 +655,17 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { await assertBoardPermission(req, companyId, "tools:view_audit"); const limitRaw = Number(req.query.limit ?? 100); const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, Math.floor(limitRaw))) : 100; + const gatewayFilter = typeof req.query.gateway === "string" ? req.query.gateway.trim() : null; const appFilter = typeof req.query.app === "string" ? req.query.app.trim() : null; const agentFilter = typeof req.query.agent === "string" ? req.query.agent.trim() : null; const outcomeFilter = typeof req.query.outcome === "string" ? req.query.outcome.trim() : null; - const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "24h"; + const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "all"; const searchRaw = typeof req.query.search === "string" ? req.query.search.trim() : null; const cursorRaw = typeof req.query.cursor === "string" ? req.query.cursor.trim() : null; + if (gatewayFilter && !uuidPattern.test(gatewayFilter)) { + res.status(400).json({ error: "gateway must be a gateway UUID" }); + return; + } if (appFilter && !uuidPattern.test(appFilter)) { res.status(400).json({ error: "app must be an applicationId or connectionId UUID" }); return; @@ -659,7 +675,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { return; } if (!(windowFilter in TOOL_GATEWAY_WINDOWS)) { - res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d" }); + res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d, all" }); return; } const cursor = cursorRaw ? decodeAuditCursor(cursorRaw) : null; @@ -668,30 +684,37 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { return; } + const windowMs = TOOL_GATEWAY_WINDOWS[windowFilter]; + const windowStartedAt = windowMs === null ? null : new Date(Date.now() - windowMs); const conditions = [ - eq(activityLog.companyId, companyId), - inArray(activityLog.action, TOOL_GATEWAY_ACTIONS), - gte(activityLog.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])), + eq(toolCallEvents.companyId, companyId), + inArray(toolCallEvents.eventType, TOOL_ACTIVITY_EVENT_TYPES), ]; + if (windowStartedAt) conditions.push(gte(toolCallEvents.createdAt, windowStartedAt)); if (cursor) { conditions.push(or( - lt(activityLog.createdAt, cursor.createdAt), - and(eq(activityLog.createdAt, cursor.createdAt), lt(activityLog.id, cursor.id)), + lt(toolCallEvents.createdAt, cursor.createdAt), + and(eq(toolCallEvents.createdAt, cursor.createdAt), lt(toolCallEvents.id, cursor.id)), + )!); + } + if (gatewayFilter) { + conditions.push(or( + eq(toolCallEvents.gatewayId, gatewayFilter), + eq(toolInvocations.gatewayId, gatewayFilter), )!); } if (appFilter) { conditions.push(or( + eq(toolCallEvents.applicationId, appFilter), + eq(toolCallEvents.connectionId, appFilter), eq(toolInvocations.applicationId, appFilter), eq(toolInvocations.connectionId, appFilter), - sql`${activityLog.details}->>'applicationId' = ${appFilter}`, - sql`${activityLog.details}->>'connectionId' = ${appFilter}`, )!); } if (agentFilter) { conditions.push(or( - eq(activityLog.agentId, agentFilter), + eq(toolCallEvents.agentId, agentFilter), eq(toolInvocations.agentId, agentFilter), - sql`${activityLog.details}->>'agentId' = ${agentFilter}`, )!); } const outcomeWhere = outcomeFilter ? outcomeCondition(outcomeFilter) : null; @@ -699,7 +722,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { // Free-text search runs server-side: resolve the term against agent / app / // connection names first, then OR those matched IDs with direct matches on - // the action name, tool name, and reason code so paginating stays honest. + // the event type, tool name, and reason code so paginating stays honest. + let matchedAgentIds: string[] = []; + let matchedConnectionIds: string[] = []; if (searchRaw) { const like = `%${searchRaw.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`; const [matchAgents, matchApps, matchConnections] = await Promise.all([ @@ -710,113 +735,256 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { db.select({ id: toolConnections.id }).from(toolConnections) .where(and(eq(toolConnections.companyId, companyId), ilike(toolConnections.name, like))), ]); - const matchedAgentIds = matchAgents.map((r) => r.id); + matchedAgentIds = matchAgents.map((r) => r.id); const matchedAppIds = matchApps.map((r) => r.id); - const matchedConnectionIds = matchConnections.map((r) => r.id); + matchedConnectionIds = matchConnections.map((r) => r.id); + if (matchedAppIds.length > 0) { + const appConnections = await db + .select({ id: toolConnections.id }) + .from(toolConnections) + .where(and( + eq(toolConnections.companyId, companyId), + inArray(toolConnections.applicationId, matchedAppIds), + )); + matchedConnectionIds = [...new Set([ + ...matchedConnectionIds, + ...appConnections.map((connection) => connection.id), + ])]; + } const searchClauses = [ - ilike(activityLog.action, like), + ilike(toolCallEvents.eventType, like), + ilike(toolCallEvents.toolName, like), + ilike(toolCallEvents.reasonCode, like), + sql`${toolCallEvents.metadata}->>'upstreamToolName' ilike ${like}`, ilike(toolInvocations.toolName, like), - sql`${activityLog.details}->>'tool' ilike ${like}`, - sql`${activityLog.details}->>'toolName' ilike ${like}`, - sql`${activityLog.details}->>'upstreamToolName' ilike ${like}`, - sql`${activityLog.details}->>'reasonCode' ilike ${like}`, ]; if (matchedAgentIds.length > 0) { - searchClauses.push(inArray(activityLog.agentId, matchedAgentIds)); + searchClauses.push(inArray(toolCallEvents.agentId, matchedAgentIds)); searchClauses.push(inArray(toolInvocations.agentId, matchedAgentIds)); - for (const id of matchedAgentIds) searchClauses.push(sql`${activityLog.details}->>'agentId' = ${id}`); } if (matchedAppIds.length > 0) { + searchClauses.push(inArray(toolCallEvents.applicationId, matchedAppIds)); searchClauses.push(inArray(toolInvocations.applicationId, matchedAppIds)); - for (const id of matchedAppIds) searchClauses.push(sql`${activityLog.details}->>'applicationId' = ${id}`); } if (matchedConnectionIds.length > 0) { + searchClauses.push(inArray(toolCallEvents.connectionId, matchedConnectionIds)); searchClauses.push(inArray(toolInvocations.connectionId, matchedConnectionIds)); - for (const id of matchedConnectionIds) searchClauses.push(sql`${activityLog.details}->>'connectionId' = ${id}`); } conditions.push(or(...searchClauses)!); } const page = await db .select({ - row: activityLog, + row: toolCallEvents, invocationId: toolInvocations.id, invocationAgentId: toolInvocations.agentId, invocationApplicationId: toolInvocations.applicationId, invocationConnectionId: toolInvocations.connectionId, invocationToolName: toolInvocations.toolName, + invocationStatus: toolInvocations.status, + invocationPolicyDecision: toolInvocations.policyDecision, + invocationApprovalState: toolInvocations.approvalState, + invocationArgumentsSummary: toolInvocations.argumentsSummary, + invocationResultSummary: toolInvocations.resultSummary, + invocationResultSizeBytes: toolInvocations.resultSizeBytes, + invocationErrorCode: toolInvocations.errorCode, + invocationErrorMessage: toolInvocations.errorMessage, + invocationStartedAt: toolInvocations.startedAt, + invocationCompletedAt: toolInvocations.completedAt, }) - .from(activityLog) + .from(toolCallEvents) .leftJoin( toolInvocations, and( eq(toolInvocations.companyId, companyId), - sql`${toolInvocations.id}::text = ${activityLog.details}->>'invocationId'`, + eq(toolInvocations.id, toolCallEvents.invocationId), ), ) .where(and(...conditions)) - .orderBy(desc(activityLog.createdAt), desc(activityLog.id)) + .orderBy(desc(toolCallEvents.createdAt), desc(toolCallEvents.id)) .limit(limit + 1); - const hasMore = page.length > limit; - const visible = hasMore ? page.slice(0, limit) : page; + let lifecycleConnectionIds: string[] | undefined; + if (gatewayFilter || outcomeFilter) { + lifecycleConnectionIds = []; + } else if (appFilter) { + lifecycleConnectionIds = (await db + .select({ id: toolConnections.id }) + .from(toolConnections) + .where(and( + eq(toolConnections.companyId, companyId), + or(eq(toolConnections.id, appFilter), eq(toolConnections.applicationId, appFilter)), + ))) + .map((row) => row.id); + } + + const lifecycleEvents = await listConnectionLifecycleEvents(db, { + companyId, + connectionIds: lifecycleConnectionIds, + agentId: agentFilter, + since: windowStartedAt, + cursor, + search: searchRaw, + matchedAgentIds, + matchedConnectionIds, + limit: limit + 1, + }); + const candidates = [ + ...page.map((item) => ({ kind: "call" as const, id: item.row.id, createdAt: item.row.createdAt, item })), + ...lifecycleEvents.map((item) => ({ kind: "lifecycle" as const, id: item.id, createdAt: item.createdAt, item })), + ].sort((a, b) => { + const byTime = b.createdAt.getTime() - a.createdAt.getTime(); + return byTime !== 0 ? byTime : b.id.localeCompare(a.id); + }); + const hasMore = candidates.length > limit; + const visible = candidates.slice(0, limit); const agentIds = [...new Set(visible.flatMap((item) => [ - item.row.agentId, - item.invocationAgentId, - detailString(item.row.details, "agentId"), + item.kind === "call" ? item.item.row.agentId : item.item.agentId, + item.kind === "call" ? item.item.invocationAgentId : null, ]).filter((id): id is string => Boolean(id)))]; const applicationIds = [...new Set(visible.flatMap((item) => [ - item.invocationApplicationId, - detailString(item.row.details, "applicationId"), + item.kind === "call" ? item.item.row.applicationId : null, + item.kind === "call" ? item.item.invocationApplicationId : null, ]).filter((id): id is string => Boolean(id)))]; const connectionIds = [...new Set(visible.flatMap((item) => [ - item.invocationConnectionId, - detailString(item.row.details, "connectionId"), + item.kind === "call" ? item.item.row.connectionId : item.item.connectionId, + item.kind === "call" ? item.item.invocationConnectionId : null, ]).filter((id): id is string => Boolean(id)))]; - const [agentRows, applicationRows, connectionRows] = await Promise.all([ + const [agentRows, connectionRows] = await Promise.all([ agentIds.length > 0 ? db.select({ id: agents.id, name: agents.name }).from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))) : [], - applicationIds.length > 0 - ? db.select({ id: toolApplications.id, name: toolApplications.name }).from(toolApplications).where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds))) - : [], connectionIds.length > 0 ? db.select({ id: toolConnections.id, name: toolConnections.name, applicationId: toolConnections.applicationId }).from(toolConnections).where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds))) : [], ]); + const allApplicationIds = [...new Set([ + ...applicationIds, + ...connectionRows.map((row) => row.applicationId), + ])]; + const applicationRows = allApplicationIds.length > 0 + ? await db.select({ id: toolApplications.id, name: toolApplications.name }).from(toolApplications) + .where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, allApplicationIds))) + : []; const agentsById = new Map(agentRows.map((row) => [row.id, row])); const applicationsById = new Map(applicationRows.map((row) => [row.id, row])); const connectionsById = new Map(connectionRows.map((row) => [row.id, row])); - const events = visible.map((item) => { + const events = visible.map((candidate) => { + if (candidate.kind === "lifecycle") { + const lifecycle = candidate.item; + const connection = connectionsById.get(lifecycle.connectionId) ?? null; + const applicationId = connection?.applicationId ?? null; + const application = applicationId ? applicationsById.get(applicationId) ?? null : null; + const appDisplayName = connection + ? humanizeConnectionDisplayName(connection) + : application + ? humanizeConnectionDisplayName(application.name) + : null; + return { + id: lifecycle.id, + companyId, + action: `tool_connection.${lifecycle.type}`, + actorType: lifecycle.actorType, + actorId: lifecycle.actorId, + entityType: "tool_connection", + entityId: lifecycle.connectionId, + details: { ...(lifecycle.details ?? {}), lifecycleType: lifecycle.type }, + createdAt: lifecycle.createdAt, + runId: null, + agentId: lifecycle.agentId, + agentDisplayName: lifecycle.agentId + ? agentsById.get(lifecycle.agentId)?.name ?? "Unknown agent" + : null, + actorDisplayName: lifecycle.actorDisplayName, + applicationId, + connectionId: lifecycle.connectionId, + appDisplayName, + applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null, + connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null, + toolDisplayName: null, + lifecycleType: lifecycle.type, + normalizedOutcome: "unknown" as const, + invocation: null, + }; + } + + const item = candidate.item; const row = item.row; - const details = row.details ?? null; - const agentId = row.agentId ?? item.invocationAgentId ?? detailString(details, "agentId"); - const connectionId = item.invocationConnectionId ?? detailString(details, "connectionId"); + const agentId = row.agentId ?? item.invocationAgentId; + const connectionId = row.connectionId ?? item.invocationConnectionId; const connection = connectionId ? connectionsById.get(connectionId) ?? null : null; - const applicationId = item.invocationApplicationId ?? detailString(details, "applicationId") ?? connection?.applicationId ?? null; + const applicationId = row.applicationId ?? item.invocationApplicationId ?? connection?.applicationId ?? null; const application = applicationId ? applicationsById.get(applicationId) ?? null : null; - const rawToolName = item.invocationToolName ?? detailString(details, "tool") ?? detailString(details, "toolName"); + const rawToolName = row.toolName ?? item.invocationToolName; const appDisplayName = connection ? humanizeConnectionDisplayName(connection) : application ? humanizeConnectionDisplayName(application.name) : null; + const details = { + ...(row.metadata ?? {}), + invocationId: row.invocationId, + actionRequestId: row.actionRequestId, + gatewayId: row.gatewayId, + agentId, + issueId: row.issueId, + runId: row.runId, + applicationId, + connectionId, + tool: rawToolName, + toolName: rawToolName, + decision: row.decision, + matchedPolicyIds: row.matchedPolicyIds, + reasonCode: row.reasonCode, + argumentsSummary: row.argumentsSummary ?? row.requestSummary, + resultSummary: row.resultSummary, + latencyMs: row.latencyMs, + errorCode: row.errorCode, + errorMessage: row.errorMessage, + }; return { - ...row, + id: row.id, + companyId: row.companyId, + action: toolActivityAction(row.eventType), + actorType: row.actorType, + actorId: row.actorId, + entityType: "tool_connection", + entityId: connectionId, + details, + createdAt: row.createdAt, + runId: row.runId, agentId, agentDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null, + actorDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null, applicationId, connectionId, appDisplayName, applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null, connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null, toolDisplayName: rawToolName ? humanizeConnectionDisplayName(rawToolName) : null, - normalizedOutcome: normalizedAuditOutcome(row.action, details), + lifecycleType: null as ToolConnectionLifecycleEventType | null, + normalizedOutcome: normalizedAuditOutcome(row.eventType, row.outcome, row.decision), + invocation: item.invocationId && item.invocationToolName && item.invocationStatus && item.invocationApprovalState + ? { + id: item.invocationId, + toolName: item.invocationToolName, + status: item.invocationStatus, + policyDecision: item.invocationPolicyDecision, + approvalState: item.invocationApprovalState, + argumentsSummary: item.invocationArgumentsSummary, + resultSummary: item.invocationResultSummary, + resultSizeBytes: item.invocationResultSizeBytes, + errorCode: item.invocationErrorCode, + errorMessage: item.invocationErrorMessage, + startedAt: item.invocationStartedAt, + completedAt: item.invocationCompletedAt, + } + : null, }; }); - const last = visible.at(-1)?.row; + const last = visible.at(-1); res.json({ events, nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null, diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 063de1e271..19107b875d 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -28,6 +28,7 @@ import { type TrustPresetResolution, } from "./trust-preset-resolver.js"; import { logger } from "../middleware/logger.js"; +import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js"; export type AuthorizationActor = { @@ -102,6 +103,7 @@ export type AuthorizationDecision = { | "allow_local_board" | "allow_instance_admin" | "allow_explicit_grant" + | "allow_role_default" | "allow_user_inbox_policy" | "allow_direct_change" | "allow_consented_change" @@ -664,6 +666,19 @@ export function authorizationService(db: Db) { const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey); if (!grant) { + if ( + input.principalType === "user" + && input.permissionKey.startsWith("tools:") + && (membership.membershipRole === "owner" || membership.membershipRole === "admin") + && grantsForHumanRole(normalizeHumanRole(membership.membershipRole, "operator")) + .some((defaultGrant) => defaultGrant.permissionKey === input.permissionKey) + ) { + return allow({ + action: input.action, + reason: "allow_role_default", + explanation: `Allowed by the ${membership.membershipRole ?? "operator"} membership role.`, + }); + } return deny({ action: input.action, reason: "deny_missing_grant", diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 0c3057c07d..2a9d781734 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -35,6 +35,10 @@ export function grantsForHumanRole( { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]; case "admin": return [ @@ -45,6 +49,10 @@ export function grantsForHumanRole( { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]; case "operator": return [{ permissionKey: "tasks:assign", scope: null }]; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index b9a635346c..8fa2aaf2c6 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -231,6 +231,10 @@ export type ExecutionWorkspaceServiceOptions = { // becomes terminal before it archives the workspace. A value of 0 disables // the cooldown. The default is 7 days. workspaceReaperCooldownDays?: number; + inspectGitCloseReadiness?: (workspace: ExecutionWorkspace) => Promise<{ + git: ExecutionWorkspaceCloseGitReadiness | null; + warnings: string[]; + }>; }; function parseGitHubRepository(repoUrl: string | null) { @@ -1219,7 +1223,20 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( return new Map( rows.map((row) => { if (!usesInheritedProjectRuntimeServices(row)) { - return [row.id, executionRuntimeServices.get(row.id) ?? []] as const; + const runtimeServiceRows = executionRuntimeServices.get(row.id) ?? []; + const workspaceRuntime = readExecutionWorkspaceConfig( + (row.metadata as Record | null) ?? null, + )?.workspaceRuntime ?? null; + return [ + row.id, + workspaceRuntime + ? selectConfiguredRuntimeServiceRows(runtimeServiceRows, workspaceRuntime, { + // Runtime rows created before shared services defaulted to project-workspace + // scope remain valid for configs owned directly by an execution workspace. + fallbackScopeTypes: ["execution_workspace"], + }) + : runtimeServiceRows, + ] as const; } const workspaceRuntime = projectRuntimeConfigByWorkspaceId.get(row.projectWorkspaceId!) ?? null; @@ -1470,7 +1487,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic async function hydrateWorkspace(row: ExecutionWorkspaceRow, runtimeServices: WorkspaceRuntimeService[] = []) { const workspace = toExecutionWorkspace(row, runtimeServices); - const { git } = await inspectGitCloseReadiness(workspace); + const { git } = await (opts.inspectGitCloseReadiness ?? inspectGitCloseReadiness)(workspace); const assessment = await assessDelivery(row, git); return toExecutionWorkspace(row, runtimeServices, assessment.deliveryState); } @@ -2063,12 +2080,16 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows); - return Promise.all(rows.map((row) => - hydrateWorkspace( + // Collection reads are deliberately DB-only. Delivery-state hydration + // inspects git and may resolve pull requests, so doing it for every row + // lets a large inventory launch an unbounded number of child processes. + // Detail and close-readiness reads retain the live hydration path. + return rows.map((row) => + toExecutionWorkspace( row, (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), ), - )); + ); }, listSummaries: async (companyId: string, filters?: { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index fca8c3bb9b..dcba14935f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -67,7 +67,10 @@ import { routines, toolMcpGateways, toolMcpGatewayTokens, + toolCatalogEntries, + toolConnectionInstalls, toolConnections, + toolProfileEntries, toolProfiles, workspaceOperations, } from "@paperclipai/db"; @@ -3652,7 +3655,68 @@ function gatewayAppliesToRun(input: { return true; } -async function createManagedMcpRunConfig(input: { +async function gatewayConnectionIds(input: { + db: Db; + companyId: string; + gateway: typeof toolMcpGateways.$inferSelect; +}): Promise> { + const managedRuntimeConnectionId = readNonEmptyString(input.gateway.metadata?.managedRuntimeConnectionId); + if (managedRuntimeConnectionId) return new Set([managedRuntimeConnectionId]); + + const [profile, entries, catalog, connections] = await Promise.all([ + input.db + .select({ defaultAction: toolProfiles.defaultAction }) + .from(toolProfiles) + .where(and(eq(toolProfiles.companyId, input.companyId), eq(toolProfiles.id, input.gateway.profileId))) + .then((rows) => rows[0] ?? null), + input.db + .select() + .from(toolProfileEntries) + .where(and( + eq(toolProfileEntries.companyId, input.companyId), + eq(toolProfileEntries.profileId, input.gateway.profileId), + )), + input.db + .select({ + id: toolCatalogEntries.id, + connectionId: toolCatalogEntries.connectionId, + applicationId: toolCatalogEntries.applicationId, + toolName: toolCatalogEntries.toolName, + riskLevel: toolCatalogEntries.riskLevel, + }) + .from(toolCatalogEntries) + .where(and(eq(toolCatalogEntries.companyId, input.companyId), eq(toolCatalogEntries.status, "active"))), + input.db + .select({ id: toolConnections.id, applicationId: toolConnections.applicationId }) + .from(toolConnections) + .where(eq(toolConnections.companyId, input.companyId)), + ]); + if (!profile) return new Set(); + if (profile.defaultAction === "allow") return new Set(catalog.map((entry) => entry.connectionId)); + + const connectionIds = new Set(); + for (const entry of entries) { + if (entry.effect !== "include") continue; + if (entry.connectionId) connectionIds.add(entry.connectionId); + if (entry.applicationId) { + for (const connection of connections) { + if (connection.applicationId === entry.applicationId) connectionIds.add(connection.id); + } + } + for (const catalogEntry of catalog) { + if ( + (entry.catalogEntryId && entry.catalogEntryId === catalogEntry.id) + || (entry.toolName && entry.toolName === catalogEntry.toolName) + || (entry.riskLevel && entry.riskLevel === catalogEntry.riskLevel) + ) { + connectionIds.add(catalogEntry.connectionId); + } + } + } + return connectionIds; +} + +export async function createManagedMcpRunConfig(input: { db: Db; agent: Pick; runId: string; @@ -3673,12 +3737,33 @@ async function createManagedMcpRunConfig(input: { )) .orderBy(asc(toolMcpGateways.name)); - const gateways = rows.filter((gateway) => gatewayAppliesToRun({ + const installRows = await input.db + .select({ connectionId: toolConnectionInstalls.connectionId }) + .from(toolConnectionInstalls) + .where(and( + eq(toolConnectionInstalls.companyId, input.agent.companyId), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${input.agent.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agent.id}))`, + )); + const installedConnectionIds = new Set(installRows.map((install) => install.connectionId)); + + const applicableGateways = rows.filter((gateway) => gatewayAppliesToRun({ gateway, agentId: input.agent.id, projectId: input.projectId, issueId: input.issueId, })); + const gateways = (await Promise.all(applicableGateways.map(async (gateway) => ({ + gateway, + connectionIds: await gatewayConnectionIds({ + db: input.db, + companyId: input.agent.companyId, + gateway, + }), + })))) + .filter(({ connectionIds }) => + connectionIds.size > 0 + && [...connectionIds].every((connectionId) => installedConnectionIds.has(connectionId))) + .map(({ gateway }) => gateway); if (gateways.length === 0) return null; const service = createToolGatewayService(input.db); diff --git a/server/src/services/remote-http-endpoint-guard.ts b/server/src/services/remote-http-endpoint-guard.ts index e4204d2da5..0c5c0b3ce3 100644 --- a/server/src/services/remote-http-endpoint-guard.ts +++ b/server/src/services/remote-http-endpoint-guard.ts @@ -53,24 +53,24 @@ export async function assertPublicRemoteHttpEndpoint( * (PAP-17098). Returning the resolved set — rather than a bare `void` — is what * lets `guardedRemoteHttpFetch` close that window. * - * An empty result means "no address pinning required": the deployment allows - * private endpoints, so there is no boundary left to enforce. + * Hostnames are always resolved and pinned, including in deployments that + * allow private networking. Link-local addresses remain outside that allowance, + * so handing an allowed hostname back to platform fetch would reopen a DNS + * rebinding path to instance metadata. */ export async function resolveApprovedRemoteHttpAddresses( endpoint: URL, options: RemoteHttpEndpointGuardOptions, error: RemoteHttpEndpointErrorFactory, ): Promise { - if (options.allowPrivateNetwork) return []; - const hostname = endpoint.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - if (hostname === "localhost" || hostname.endsWith(".localhost")) { + if (!options.allowPrivateNetwork && (hostname === "localhost" || hostname.endsWith(".localhost"))) { throw error("Remote MCP connection URL cannot target private or reserved network addresses", "remote_http_private_endpoint"); } const literalVersion = isIP(hostname); if (literalVersion !== 0) { - if (isPrivateOrReservedIp(hostname)) { + if (isAlwaysDeniedLinkLocalIp(hostname) || (!options.allowPrivateNetwork && isPrivateOrReservedIp(hostname))) { throw error("Remote MCP connection URL cannot target private or reserved network addresses", "remote_http_private_endpoint"); } return [hostname]; @@ -89,7 +89,9 @@ export async function resolveApprovedRemoteHttpAddresses( if (results.length === 0) { throw error("Remote MCP connection hostname did not resolve", "remote_http_dns_failed"); } - if (results.some((result) => isPrivateOrReservedIp(result.address))) { + if (results.some((result) => + isAlwaysDeniedLinkLocalIp(result.address) + || (!options.allowPrivateNetwork && isPrivateOrReservedIp(result.address)))) { throw error("Remote MCP connection URL cannot resolve to private or reserved network addresses", "remote_http_private_endpoint"); } return results.map((result) => result.address); @@ -142,6 +144,16 @@ export function isPrivateOrReservedIp(address: string): boolean { return true; } +/** Link-local egress is denied in every deployment mode. */ +export function isAlwaysDeniedLinkLocalIp(address: string): boolean { + const normalized = normalizeIpAddress(address); + if (isIP(normalized) === 4) { + const octets = parseIpv4Address(normalized); + return octets !== null && octets[0] === 169 && octets[1] === 254; + } + return isIP(normalized) === 6 && /^fe[89ab]/.test(normalized); +} + function isPrivateOrReservedIpv4(address: string): boolean { const octets = parseIpv4Address(address); if (!octets) return true; diff --git a/server/src/services/remote-http-fetch.ts b/server/src/services/remote-http-fetch.ts index 5190df97df..0915dfa5c4 100644 --- a/server/src/services/remote-http-fetch.ts +++ b/server/src/services/remote-http-fetch.ts @@ -6,6 +6,7 @@ import { connect as tlsConnect, type TLSSocket } from "node:tls"; import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; import { + isAlwaysDeniedLinkLocalIp, isPrivateOrReservedIp, normalizeIpAddress, resolveApprovedRemoteHttpAddresses, @@ -56,8 +57,7 @@ export type GuardedRemoteHttpFetchOptions = RemoteHttpEndpointGuardOptions & { /** Deadline for response headers, and idle deadline between body chunks. */ responseTimeoutMs?: number; /** - * Platform `fetch`, used only when the deployment allows private endpoints and - * there is therefore no egress boundary to pin against. + * Platform `fetch`, used only for IP literals, which cannot be rebound. */ unpinnedFetch?: typeof fetch; }; @@ -84,12 +84,8 @@ export type GuardedRemoteHttpFetchOptions = RemoteHttpEndpointGuardOptions & { * 5. never follows redirects — it behaves as `redirect: "manual"` so the caller * re-runs the whole guard against every `Location` it decides to follow. * - * Two cases need no pinning and keep platform `fetch` semantics: - * - * - the deployment allows private endpoints, where the guard is a documented - * no-op and rebinding cannot reach anything an operator could not reach by - * typing the private URL in directly; and - * - the URL already carries an IP literal, where no name resolution happens on + * URLs that already carry an IP literal need no pinning and keep platform + * `fetch` semantics: no name resolution happens on * either side of the guard, so there is no second answer to disagree with the * first. `URL` has already normalised the literal (`0x7f.1`, `::ffff:7f00:1`) * by the time the guard classifies it. @@ -103,7 +99,7 @@ export async function guardedRemoteHttpFetch( const approved = await resolveApprovedRemoteHttpAddresses(endpoint, options, options.error); const literalHost = isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) !== 0; const platformFetch = options.unpinnedFetch ?? fetch; - if (approved.length === 0 || literalHost) { + if (literalHost) { try { return await platformFetch(endpoint.toString(), { ...init, redirect: "manual" }); } catch (error) { @@ -237,7 +233,11 @@ async function openVerifiedSocket(input: { } const peer = raw.remoteAddress ? normalizeIpAddress(raw.remoteAddress) : null; - if (!peer || isPrivateOrReservedIp(peer) || !approvedSet.has(peer)) { + const peerDenied = peer && ( + isAlwaysDeniedLinkLocalIp(peer) + || (!options.allowPrivateNetwork && isPrivateOrReservedIp(peer)) + ); + if (!peer || peerDenied || !approvedSet.has(peer)) { raw.destroy(); throw options.error( "Remote MCP connection resolved to an address that was not approved", diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 2a68a8aca2..6614218681 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -3,7 +3,6 @@ import { readFileSync } from "node:fs"; import { and, asc, desc, eq, gte, inArray, isNull, lt, max, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { - activityLog, agents, connectionGrants, connectionTokenIssuances, @@ -78,8 +77,6 @@ import type { ToolActionRequestListItem, ToolActionRequestStatus, ToolConnectionActivityResponse, - ToolConnectionLifecycleEvent, - ToolConnectionLifecycleEventType, ToolAppConnectionActionSummary, ToolExampleInstallResult, ToolExampleSmokeCheck, @@ -146,6 +143,7 @@ import { } from "./tool-profile-binding-precedence.js"; import { recordToolRuntimeAuditWriteFailure, TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC } from "./tool-runtime-metrics.js"; import { createToolRuntimeSupervisor, ToolRuntimeSupervisorError } from "./tool-runtime-supervisor.js"; +import { listConnectionLifecycleEvents } from "./tool-connection-activity.js"; type ActorInfo = { actorType?: "agent" | "user" | "system" | "plugin"; @@ -449,7 +447,7 @@ function sameOAuthIssuer(a: string | null | undefined, b: string | null | undefi const oauthRegistrationFlights = new Map>(); -async function oauthSingleFlight( +async function singleFlight( flights: Map>, key: string, operation: () => Promise, @@ -470,8 +468,14 @@ type ToolAccessServiceOptions = { deploymentExposure?: DeploymentExposure; trustedLocalStdioRuntimeHost?: string | null; now?: () => Date; + /** How long persisted remote MCP action discovery remains fresh. */ + catalogCacheTtlMs?: number; /** Test seam for deciding whether an OAuth client metadata URL is publicly resolvable. */ oauthClientMetadataLookup?: RemoteHttpEndpointLookup; + /** Test seam for deterministic remote endpoint resolution. Production uses DNS. */ + remoteHttpEndpointLookup?: RemoteHttpEndpointLookup; + /** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */ + remoteHttpRequest?: (url: string, init: RequestInit) => Promise; }; type DbTransaction = Parameters[0]>[0]; @@ -1349,47 +1353,6 @@ function userFallbackName(userId: string): string { return userId; } -/** Activity-log actions that map to a connection lifecycle event on the Activity tab (PAP-11284). */ -const LIFECYCLE_ACTIVITY_LOG_ACTIONS = [ - "tool_app.connected", - "tool_app.oauth_connected", - "tool_example.installed", - "tool_app.reconnected", - "tool_connection.archived", - "tool_connection.updated", -] as const; - -/** - * Map a connection-scoped activity-log row to a lifecycle event type, or null - * when it isn't an operator-visible lifecycle change. A `tool_connection.updated` - * row only surfaces when the route tagged it with a `lifecycle` discriminator - * (pause/resume/allowlist); plain settings edits stay out of the feed. - */ -function activityLogActionToLifecycleType( - action: string, - details: Record | null, -): ToolConnectionLifecycleEventType | null { - switch (action) { - case "tool_app.connected": - case "tool_app.oauth_connected": - case "tool_example.installed": - return "app_connected"; - case "tool_app.reconnected": - return "reconnected"; - case "tool_connection.archived": - return "disconnected"; - case "tool_connection.updated": { - const lifecycle = typeof details?.lifecycle === "string" ? details.lifecycle : null; - if (lifecycle === "paused") return "app_paused"; - if (lifecycle === "resumed") return "app_resumed"; - if (lifecycle === "allowlist_changed") return "allowlist_changed"; - return null; - } - default: - return null; - } -} - function denialReasonForDecision( invocation: typeof toolInvocations.$inferSelect, latestAuditEvent: typeof toolCallEvents.$inferSelect | null, @@ -1832,9 +1795,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); const runtimeSupervisor = createToolRuntimeSupervisor(db, options); - // This map only removes duplicate work inside one service instance. The - // database refresh lease below is the cross-process serialization boundary. + // These maps remove duplicate work inside one service instance. OAuth also + // uses the database refresh lease below as its cross-process boundary. const oauthRefreshFlights = new Map>(); + const catalogRefreshFlights = new Map>(); + const catalogCacheTtlMs = Math.max(0, options.catalogCacheTtlMs ?? 15 * 60 * 1000); function allowPrivateRemoteEndpoints() { return options.deploymentMode !== "authenticated" || options.deploymentExposure !== "public"; @@ -1844,7 +1809,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const endpoint = parseRemoteHttpEndpoint(value, (message, code) => badRequest(message, { code })); await assertPublicRemoteHttpEndpoint( endpoint, - { allowPrivateNetwork: allowPrivateRemoteEndpoints() }, + { + allowPrivateNetwork: allowPrivateRemoteEndpoints(), + lookup: options.remoteHttpEndpointLookup, + }, (message, code) => badRequest(message, { code }), ); return endpoint.toString(); @@ -1853,10 +1821,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} function remoteHttpFetchOptions(): GuardedRemoteHttpFetchOptions { return { allowPrivateNetwork: allowPrivateRemoteEndpoints(), + lookup: options.remoteHttpEndpointLookup, error: (message, code) => badRequest(message, { code }), }; } + async function requestRemoteHttpEndpoint(endpoint: URL, init: RequestInit): Promise { + return options.remoteHttpRequest + ? options.remoteHttpRequest(endpoint.toString(), { ...init, redirect: "manual" }) + : guardedRemoteHttpFetch(endpoint, init, remoteHttpFetchOptions()); + } + /** * Fetch an operator-supplied remote URL with the egress guard bound to the * connection itself. @@ -1872,7 +1847,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const method = (init.method ?? "GET").toUpperCase(); for (let redirectCount = 0; redirectCount <= MAX_REMOTE_HTTP_REDIRECTS; redirectCount += 1) { const endpoint = parseRemoteHttpEndpoint(currentUrl, (message, code) => badRequest(message, { code })); - const response = await guardedRemoteHttpFetch(endpoint, init, remoteHttpFetchOptions()); + const response = await requestRemoteHttpEndpoint(endpoint, init); const location = REMOTE_HTTP_REDIRECT_STATUSES.has(response.status) ? response.headers?.get?.("location") ?? null : null; @@ -3981,7 +3956,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} // Pinned to the address the guard approved: `config.url` is operator-supplied, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. - const response = await guardedRemoteHttpFetch(remoteEndpoint(connection.config), { + const response = await requestRemoteHttpEndpoint(new URL(remoteEndpoint(connection.config)), { method: "POST", // MCP Streamable HTTP requires advertising that we accept both a JSON body // and an SSE stream; spec-compliant servers 406 without it (see mcp-http.ts). @@ -3992,7 +3967,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} method: "tools/list", params: {}, }), - }, remoteHttpFetchOptions()); + }); if (!response.ok) { const authenticate = response.headers.get("www-authenticate") ?? ""; if (response.status === 401 && /bearer|oauth|authorization/i.test(authenticate)) { @@ -4136,9 +4111,13 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } } - async function refreshCatalog(connectionId: string, actor?: ActorInfo): Promise { + async function refreshCatalog( + connectionId: string, + actor?: ActorInfo, + refreshOptions: { enableAllByDefault?: boolean } = {}, + ): Promise { const connection = await getConnectionRow(connectionId); - const now = new Date(); + const refreshedAt = now(); let descriptors: McpToolDescriptor[]; try { descriptors = await discoverTools(connection); @@ -4168,7 +4147,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const sourceTemplateKey = typeof asRecord(connection.config).sourceTemplateKey === "string" ? String(asRecord(connection.config).sourceTemplateKey) : null; - const quarantineOnRefresh = shouldQuarantineNewEntries(connection) + const quarantineOnRefresh = !refreshOptions.enableAllByDefault + && shouldQuarantineNewEntries(connection) && (connection.status === "active" || sourceTemplateKey === "posthog"); const safeDefault = asRecord(connection.config).safeDefault === true; for (const descriptor of descriptors) { @@ -4206,14 +4186,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} status, versionHash: hash, schemaHash, - lastSeenAt: now, + lastSeenAt: refreshedAt, quarantinedAt: status === "quarantined" - ? shouldQuarantine ? now : existing.quarantinedAt + ? shouldQuarantine ? refreshedAt : existing.quarantinedAt : null, quarantineReason: status === "quarantined" ? shouldQuarantine ? "pending_review" : existing.quarantineReason : null, - updatedAt: now, + updatedAt: refreshedAt, }) .where(eq(toolCatalogEntries.id, existing.id)) .returning(); @@ -4237,25 +4217,33 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} status, versionHash: hash, schemaHash, - firstSeenAt: now, - lastSeenAt: now, - quarantinedAt: shouldQuarantine ? now : null, + firstSeenAt: refreshedAt, + lastSeenAt: refreshedAt, + quarantinedAt: shouldQuarantine ? refreshedAt : null, quarantineReason: shouldQuarantine ? "pending_review" : null, }).returning(); updatedEntries.push(toCatalogEntry(created)); } } + const normalizedConfig = refreshOptions.enableAllByDefault + ? { ...connection.config, quarantineNewEntries: false } + : connection.config; + const normalizedTransportConfig = refreshOptions.enableAllByDefault + ? { ...connection.transportConfig, quarantineNewEntries: false } + : connection.transportConfig; const [updatedConnection] = await db .update(toolConnections) .set({ + config: normalizedConfig, + transportConfig: normalizedTransportConfig, healthStatus: "ok", healthMessage: "Tool catalog refreshed.", - healthCheckedAt: now, - lastHealthAt: now, - lastCatalogRefreshAt: now, + healthCheckedAt: refreshedAt, + lastHealthAt: refreshedAt, + lastCatalogRefreshAt: refreshedAt, lastError: null, - updatedAt: now, + updatedAt: refreshedAt, }) .where(eq(toolConnections.id, connection.id)) .returning(); @@ -4264,22 +4252,37 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await ensureRuntimeSlot(updatedConnection); await db .update(toolRuntimeSlots) - .set({ healthStatus: "ok", healthMessage: "Approved stdio template is ready.", lastHealthCheckAt: now, updatedAt: now }) + .set({ + healthStatus: "ok", + healthMessage: "Approved stdio template is ready.", + lastHealthCheckAt: refreshedAt, + updatedAt: refreshedAt, + }) .where(eq(toolRuntimeSlots.connectionId, connection.id)); } const activeEntries = updatedEntries.filter((entry) => entry.status === "active"); await enableCatalogEntriesByDefault({ connection: updatedConnection, - newCatalogEntryIds: activeEntries - .filter((entry) => { - const previous = existingByName.get(entry.toolName); - return !previous || previous.status === "quarantined"; - }) - .map((entry) => entry.id), + newCatalogEntryIds: refreshOptions.enableAllByDefault + ? activeEntries.map((entry) => entry.id) + : activeEntries + .filter((entry) => { + const previous = existingByName.get(entry.toolName); + return !previous || previous.status === "quarantined"; + }) + .map((entry) => entry.id), activeCatalogEntryIds: activeEntries.map((entry) => entry.id), actor, }); + if (refreshOptions.enableAllByDefault) { + await upsertAskFirstPolicies({ + companyId: updatedConnection.companyId, + connection: updatedConnection, + askFirstEntries: [], + actor, + }); + } await audit({ companyId: connection.companyId, @@ -5874,7 +5877,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } const key = `${input.connection.id}:${input.redirectUri}`; - return oauthSingleFlight(oauthRegistrationFlights, key, async () => { + return singleFlight(oauthRegistrationFlights, key, async () => { const latest = await getConnectionRow(input.connection.id, input.connection.companyId); const latestConfigured = configuredOAuthClientForConnection(latest, input.endpoints.provider); if (latestConfigured.clientId) { @@ -6345,7 +6348,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (typeof oauth.tokenUrl !== "string" || typeof oauth.provider !== "string") return connection; const expiresAtMs = oauthExpiresAtMs(connection); if (expiresAtMs && expiresAtMs > Date.now() + 60_000) return connection; - return oauthSingleFlight(oauthRefreshFlights, connection.id, async () => { + return singleFlight(oauthRefreshFlights, connection.id, async () => { const lease = await acquireOAuthRefreshLease(connection); if (!lease.leaseId) return lease.connection; try { @@ -6641,7 +6644,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } throw error; } - const refresh = await refreshCatalog(connectionRow.id, actor); + const refresh = await refreshCatalog(connectionRow.id, actor, { enableAllByDefault: true }); const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, applicationRow.id)); return { connectionId: refresh.connection.id, @@ -7082,7 +7085,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .where(eq(toolConnections.id, connection.id)) .returning(); await syncCredentialBindings(updated); - return checkConnectionHealth(updated.id, actor); + const health = await checkConnectionHealth(updated.id, actor); + const refresh = await refreshCatalog(updated.id, actor, { enableAllByDefault: true }); + return { ...health, connection: refresh.connection }; } async function startOAuth( @@ -7257,6 +7262,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .select({ companyId: toolOauthStates.companyId, connectionId: toolOauthStates.connectionId, + subjectUserId: toolOauthStates.subjectUserId, }) .from(toolOauthStates) .where(eq(toolOauthStates.state, state)) @@ -7542,7 +7548,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await syncCredentialBindings(connection); await checkConnectionHealth(connection.id, input.actor); - const refresh = await refreshCatalog(connection.id, input.actor); + const refresh = await refreshCatalog(connection.id, input.actor, { enableAllByDefault: true }); const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId)); return { connectionId: refresh.connection.id, @@ -7561,137 +7567,6 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} }; } - /** - * Build the connection lifecycle timeline for the Activity tab (PAP-11284) by - * surfacing two existing audit sources scoped to this connection: - * - `activity_log` rows (connect / pause / resume / allowlist / reconnect / disconnect) - * - `tool_access_audit_events` catalog refreshes that quarantined new actions - * Actors are resolved to display names (agent name or user name/email). - */ - async function listConnectionLifecycleEvents( - connection: typeof toolConnections.$inferSelect, - limit: number, - ): Promise { - const [logRows, quarantineRows] = await Promise.all([ - db - .select() - .from(activityLog) - .where( - and( - eq(activityLog.companyId, connection.companyId), - eq(activityLog.entityType, "tool_connection"), - eq(activityLog.entityId, connection.id), - inArray(activityLog.action, [...LIFECYCLE_ACTIVITY_LOG_ACTIONS]), - ), - ) - .orderBy(desc(activityLog.createdAt)) - .limit(limit), - db - .select() - .from(toolAccessAuditEvents) - .where( - and( - eq(toolAccessAuditEvents.companyId, connection.companyId), - eq(toolAccessAuditEvents.connectionId, connection.id), - eq(toolAccessAuditEvents.action, "tool_connection.catalog_refresh"), - sql`(${toolAccessAuditEvents.details}->>'quarantinedCount')::int > 0`, - ), - ) - .orderBy(desc(toolAccessAuditEvents.createdAt)) - .limit(limit), - ]); - - type Pending = { - id: string; - type: ToolConnectionLifecycleEventType; - actorType: ToolConnectionLifecycleEvent["actorType"]; - actorId: string | null; - agentId: string | null; - details: Record | null; - createdAt: Date; - }; - const pending: Pending[] = []; - - for (const row of logRows) { - const type = activityLogActionToLifecycleType(row.action, row.details ?? null); - if (!type) continue; - pending.push({ - id: row.id, - type, - actorType: (row.actorType as Pending["actorType"]) ?? "system", - actorId: row.actorId ?? null, - agentId: row.agentId ?? null, - details: row.details ?? null, - createdAt: row.createdAt, - }); - } - - for (const row of quarantineRows) { - const count = Number((row.details as Record | null)?.quarantinedCount ?? 0); - pending.push({ - id: row.id, - type: "actions_quarantined", - actorType: (row.actorType as Pending["actorType"]) ?? "system", - actorId: row.actorId ?? null, - agentId: null, - details: { count: Number.isFinite(count) ? count : 0 }, - createdAt: row.createdAt, - }); - } - - pending.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - const limited = pending.slice(0, limit); - - // Resolve actor display names in batch. Agent actors carry their id in - // `agentId` (activity log) or `actorId` (audit events); user actors carry a - // user id in `actorId`. - const agentIds = new Set(); - const userIds = new Set(); - for (const item of limited) { - if (item.agentId) agentIds.add(item.agentId); - if (item.actorType === "agent" && item.actorId) agentIds.add(item.actorId); - if (item.actorType === "user" && item.actorId && item.actorId !== "board") userIds.add(item.actorId); - } - const agentRows = agentIds.size - ? await db - .select({ id: agents.id, name: agents.name }) - .from(agents) - .where(and(eq(agents.companyId, connection.companyId), inArray(agents.id, [...agentIds]))) - : []; - const userRows = userIds.size - ? await db - .select({ id: authUsers.id, name: authUsers.name, email: authUsers.email }) - .from(authUsers) - .where(inArray(authUsers.id, [...userIds])) - : []; - const agentNames = new Map(agentRows.map((agent) => [agent.id, agent.name])); - const userNames = new Map( - userRows.map((user) => [user.id, user.name?.trim() || user.email?.trim() || user.id]), - ); - - return limited.map((item) => { - let actorDisplayName: string | null = null; - if (item.agentId) actorDisplayName = agentNames.get(item.agentId) ?? null; - else if (item.actorType === "agent" && item.actorId) actorDisplayName = agentNames.get(item.actorId) ?? null; - else if (item.actorType === "user" && item.actorId) { - actorDisplayName = item.actorId === "board" - ? "The board" - : userNames.get(item.actorId) ?? userFallbackName(item.actorId); - } - return { - id: item.id, - connectionId: connection.id, - type: item.type, - actorType: item.actorType, - actorId: item.actorId, - agentId: item.agentId, - actorDisplayName, - details: item.details, - createdAt: item.createdAt, - }; - }); - } - return { approvedStdioTemplates: async (companyId: string): Promise => { const adminTemplates = await db @@ -8062,12 +7937,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return connections; }, - createConnection: async (companyId: string, input: CreateToolConnection): Promise => { + createConnection: async (companyId: string, input: CreateToolConnection, actor?: ActorInfo): Promise => { let applicationId = input.applicationId; let applicationNamespace = input.applicationName ?? input.name; const transport = input.transport; if (!transport) throw badRequest("Tool connection transport is required"); const config = normalizeGoogleSheetsConnectionConfig(input.config ?? input.transportConfig ?? {}); + // Validate company-scoped references before touching a caller-supplied + // network endpoint. Besides failing fast, this keeps cross-company + // authorization errors from being masked by DNS or SSRF validation. + await assertSecretRefs(companyId, [...(input.credentialRefs ?? []), ...(input.credentialSecretRefs ?? [])]); if (transport === "mcp_remote") await assertRemoteConnectionEndpointsAllowed(config); if (transport === "local_stdio") await stdioTemplateId(companyId, config); assertLocalStdioCanBeEnabled(transport, input.enabled ?? false); @@ -8089,8 +7968,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} }).returning(); applicationId = app.id; } - await assertSecretRefs(companyId, [...(input.credentialRefs ?? []), ...(input.credentialSecretRefs ?? [])]); const connectionId = randomUUID(); + const binding = actorBinding(actor); const [row] = await db.insert(toolConnections).values({ id: connectionId, companyId, @@ -8107,6 +7986,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} transportConfig: isGoogleSheetsConnectionConfig(config) ? config : input.transportConfig ?? config, credentialRefs: input.credentialRefs ?? [], credentialSecretRefs: input.credentialSecretRefs ?? [], + createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, + createdByUserId: binding.actorType === "user" ? binding.actorId : null, }).returning(); await ensureDefaultWorkspaceGrant(row); await syncCredentialBindings(row); @@ -8155,6 +8036,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} createdByUserId: binding.actorType === "user" ? binding.actorId : null, }).returning(); if (!grant) throw new Error("Failed to create connection installation"); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.created", + outcome: "success", + reasonCode: "grant_created", + details: { grantId: grant.id, kind: grant.kind, isDefault: grant.isDefault }, + }); return grant; }, @@ -8174,6 +8065,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} eq(connectionGrants.connectionId, connection.id), )).returning(); if (!grant) throw notFound("Connection grant not found"); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.revoked", + outcome: "success", + reasonCode: "grant_revoked", + details: { grantId: grant.id, kind: grant.kind }, + }); return grant; }, @@ -8286,6 +8187,24 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (binding) accessExtensions.push({ targetType: install.targetType, targetId: install.targetId, profileId: profile.id }); } } + if (removeIds.length > 0 || additions.length > 0) { + const binding = actorBinding(actor); + await tx.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_installs.changed", + outcome: "success", + reasonCode: "installs_changed", + details: { + added: additions.map((install) => ({ targetType: install.targetType, targetId: install.targetId })), + removed: existing + .filter((install) => removeIds.includes(install.id)) + .map((install) => ({ targetType: install.targetType, targetId: install.targetId })), + }, + }); + } }); for (const extension of accessExtensions) { await logActivity(db, { @@ -8340,11 +8259,37 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} listCatalog: async (connectionId: string, companyId?: string): Promise => { const connection = await getConnectionRow(connectionId, companyId); - const rows = await db + let rows = await db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connection.id)) .orderBy(desc(toolCatalogEntries.updatedAt)); + const cacheExpired = connection.transport === "mcp_remote" + && connection.status !== "archived" + && ( + rows.length === 0 + || !connection.lastCatalogRefreshAt + || connection.lastCatalogRefreshAt.getTime() <= now().getTime() - catalogCacheTtlMs + ); + if (cacheExpired) { + try { + await singleFlight( + catalogRefreshFlights, + connection.id, + () => refreshCatalog(connection.id, { actorType: "system", actorId: "tool_catalog_cache" }), + ); + rows = await db + .select() + .from(toolCatalogEntries) + .where(eq(toolCatalogEntries.connectionId, connection.id)) + .orderBy(desc(toolCatalogEntries.updatedAt)); + } catch (error) { + // A stale catalog remains useful when the remote server is temporarily + // unavailable. Empty caches still fail so callers never mistake “no + // actions discovered” for a successful lookup. + if (rows.length === 0) throw error; + } + } return rows.map((row) => toCatalogEntryForConnection(row, connection)); }, @@ -8440,7 +8385,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} ]), ); - const lifecycleEvents = await listConnectionLifecycleEvents(connection, safeLimit); + const lifecycleEvents = await listConnectionLifecycleEvents(db, { + companyId: connection.companyId, + connectionIds: [connection.id], + limit: safeLimit, + }); return { connectionId: connection.id, @@ -9068,6 +9017,29 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} throw new HttpError(status, message, { code: errorCode, path, ...details }); }; + const [install] = await db + .select({ id: toolConnectionInstalls.id }) + .from(toolConnectionInstalls) + .where(and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`, + )) + .limit(1); + if (!install) { + await fail( + 403, + `Connection ${connection.name} must be installed for this agent before it can mint a token`, + "denied", + "installation_required", + { + connection: { id: connection.id, uid: connection.uid, name: connection.name }, + agentId: input.agentId, + remediation: { action: "install_connection", targetType: "agent", targetId: input.agentId }, + }, + ); + } + const subject = input.body.subject ?? { type: "app" as const }; if (subject.type === "user" && subject.userId !== runContext.responsibleUserId) { await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", { diff --git a/server/src/services/tool-connection-activity.ts b/server/src/services/tool-connection-activity.ts new file mode 100644 index 0000000000..40676cd9a8 --- /dev/null +++ b/server/src/services/tool-connection-activity.ts @@ -0,0 +1,229 @@ +import { and, desc, eq, gte, ilike, inArray, lt, or, sql, type SQL } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + activityLog, + agents, + authUsers, + toolAccessAuditEvents, +} from "@paperclipai/db"; +import type { + ToolConnectionLifecycleEvent, + ToolConnectionLifecycleEventType, +} from "@paperclipai/shared"; + +/** Activity-log actions rendered as connection lifecycle rows. */ +export const CONNECTION_LIFECYCLE_ACTIONS = [ + "tool_app.connected", + "tool_app.oauth_connected", + "tool_example.installed", + "tool_app.reconnected", + "tool_connection.archived", + "tool_connection.updated", +] as const; + +export function connectionLifecycleType( + action: string, + details: Record | null, +): ToolConnectionLifecycleEventType | null { + switch (action) { + case "tool_app.connected": + case "tool_app.oauth_connected": + case "tool_example.installed": + return "app_connected"; + case "tool_app.reconnected": + return "reconnected"; + case "tool_connection.archived": + return "disconnected"; + case "tool_connection.updated": { + const lifecycle = typeof details?.lifecycle === "string" ? details.lifecycle : null; + if (lifecycle === "paused") return "app_paused"; + if (lifecycle === "resumed") return "app_resumed"; + if (lifecycle === "allowlist_changed") return "allowlist_changed"; + return null; + } + default: + return null; + } +} + +type ActivityCursor = { createdAt: Date; id: string }; + +type ListConnectionLifecycleEventsInput = { + companyId: string; + /** Omit for every connection in the company. An empty list returns no rows. */ + connectionIds?: string[]; + agentId?: string | null; + since?: Date | null; + cursor?: ActivityCursor | null; + search?: string | null; + matchedAgentIds?: string[]; + matchedConnectionIds?: string[]; + limit: number; +}; + +function userFallbackName(userId: string): string { + if (userId === "local-board") return "Board"; + return userId; +} + +/** + * Read the lifecycle half of a connection activity timeline. Both the + * per-connection page and the aggregate Apps Activity page call this helper so + * they cannot drift onto different tables or event vocabularies. + */ +export async function listConnectionLifecycleEvents( + db: Db, + input: ListConnectionLifecycleEventsInput, +): Promise { + if (input.connectionIds?.length === 0) return []; + + const safeLimit = Math.max(1, Math.min(101, Math.floor(input.limit))); + const activityConditions: SQL[] = [ + eq(activityLog.companyId, input.companyId), + eq(activityLog.entityType, "tool_connection"), + inArray(activityLog.action, [...CONNECTION_LIFECYCLE_ACTIONS]), + ]; + const quarantineConditions: SQL[] = [ + eq(toolAccessAuditEvents.companyId, input.companyId), + eq(toolAccessAuditEvents.action, "tool_connection.catalog_refresh"), + sql`(${toolAccessAuditEvents.details}->>'quarantinedCount')::int > 0`, + ]; + + if (input.connectionIds) { + activityConditions.push(inArray(activityLog.entityId, input.connectionIds)); + quarantineConditions.push(inArray(toolAccessAuditEvents.connectionId, input.connectionIds)); + } + if (input.agentId) { + activityConditions.push(eq(activityLog.agentId, input.agentId)); + } + if (input.since) { + activityConditions.push(gte(activityLog.createdAt, input.since)); + quarantineConditions.push(gte(toolAccessAuditEvents.createdAt, input.since)); + } + if (input.cursor) { + activityConditions.push(or( + lt(activityLog.createdAt, input.cursor.createdAt), + and(eq(activityLog.createdAt, input.cursor.createdAt), lt(activityLog.id, input.cursor.id)), + )!); + quarantineConditions.push(or( + lt(toolAccessAuditEvents.createdAt, input.cursor.createdAt), + and( + eq(toolAccessAuditEvents.createdAt, input.cursor.createdAt), + lt(toolAccessAuditEvents.id, input.cursor.id), + ), + )!); + } + if (input.search) { + const like = `%${input.search.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`; + const activitySearch: SQL[] = [ + ilike(activityLog.action, like), + sql`${activityLog.details}::text ilike ${like}`, + ]; + const quarantineSearch: SQL[] = [ + ilike(toolAccessAuditEvents.action, like), + sql`${toolAccessAuditEvents.details}::text ilike ${like}`, + ]; + if (input.matchedAgentIds?.length) { + activitySearch.push(inArray(activityLog.agentId, input.matchedAgentIds)); + } + if (input.matchedConnectionIds?.length) { + activitySearch.push(inArray(activityLog.entityId, input.matchedConnectionIds)); + quarantineSearch.push(inArray(toolAccessAuditEvents.connectionId, input.matchedConnectionIds)); + } + activityConditions.push(or(...activitySearch)!); + quarantineConditions.push(or(...quarantineSearch)!); + } + + const [logRows, quarantineRows] = await Promise.all([ + db + .select() + .from(activityLog) + .where(and(...activityConditions)) + .orderBy(desc(activityLog.createdAt), desc(activityLog.id)) + .limit(safeLimit), + input.agentId + ? [] + : db + .select() + .from(toolAccessAuditEvents) + .where(and(...quarantineConditions)) + .orderBy(desc(toolAccessAuditEvents.createdAt), desc(toolAccessAuditEvents.id)) + .limit(safeLimit), + ]); + + type Pending = Omit; + const pending: Pending[] = []; + for (const row of logRows) { + const type = connectionLifecycleType(row.action, row.details ?? null); + if (!type) continue; + pending.push({ + id: row.id, + connectionId: row.entityId, + type, + actorType: (row.actorType as Pending["actorType"]) ?? "system", + actorId: row.actorId ?? null, + agentId: row.agentId ?? null, + details: row.details ?? null, + createdAt: row.createdAt, + }); + } + for (const row of quarantineRows) { + if (!row.connectionId) continue; + const count = Number((row.details as Record | null)?.quarantinedCount ?? 0); + pending.push({ + id: row.id, + connectionId: row.connectionId, + type: "actions_quarantined", + actorType: (row.actorType as Pending["actorType"]) ?? "system", + actorId: row.actorId ?? null, + agentId: null, + details: { count: Number.isFinite(count) ? count : 0 }, + createdAt: row.createdAt, + }); + } + + pending.sort((a, b) => { + const byTime = b.createdAt.getTime() - a.createdAt.getTime(); + return byTime !== 0 ? byTime : b.id.localeCompare(a.id); + }); + const limited = pending.slice(0, safeLimit); + + const agentIds = new Set(); + const userIds = new Set(); + for (const item of limited) { + if (item.agentId) agentIds.add(item.agentId); + if (item.actorType === "agent" && item.actorId) agentIds.add(item.actorId); + if (item.actorType === "user" && item.actorId && item.actorId !== "board") userIds.add(item.actorId); + } + const [agentRows, userRows] = await Promise.all([ + agentIds.size + ? db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(and(eq(agents.companyId, input.companyId), inArray(agents.id, [...agentIds]))) + : [], + userIds.size + ? db + .select({ id: authUsers.id, name: authUsers.name, email: authUsers.email }) + .from(authUsers) + .where(inArray(authUsers.id, [...userIds])) + : [], + ]); + const agentNames = new Map(agentRows.map((agent) => [agent.id, agent.name])); + const userNames = new Map( + userRows.map((user) => [user.id, user.name?.trim() || user.email?.trim() || user.id]), + ); + + return limited.map((item) => { + let actorDisplayName: string | null = null; + if (item.agentId) actorDisplayName = agentNames.get(item.agentId) ?? null; + else if (item.actorType === "agent" && item.actorId) { + actorDisplayName = agentNames.get(item.actorId) ?? null; + } else if (item.actorType === "user" && item.actorId) { + actorDisplayName = item.actorId === "board" + ? "The board" + : userNames.get(item.actorId) ?? userFallbackName(item.actorId); + } + return { ...item, actorDisplayName }; + }); +} diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 7a22e8c07f..023317ed53 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -758,6 +758,8 @@ export function createToolGatewayService( trustedLocalStdioRuntimeHost?: string | null; runtimeSupervisor?: ToolRuntimeSupervisorOptions; toolActionSigningSecret?: string; + /** Test seam for deterministic remote MCP protocol fixtures. */ + remoteHttpRequest?: (url: string, init: RequestInit) => Promise; mcpGatewayProtocolLimits?: Partial<{ authFailures: Partial; gatewayRequests: Partial; @@ -3091,7 +3093,7 @@ export function createToolGatewayService( // address it approved, so an operator-supplied hostname cannot be rebound // onto a loopback or metadata address between validation and dispatch // (PAP-17098). - const response = await guardedRemoteHttpFetch(endpoint, { + const requestInit: RequestInit = { method: "POST", redirect: "manual", // MCP Streamable HTTP requires the Accept header advertising both a JSON @@ -3107,13 +3109,16 @@ export function createToolGatewayService( arguments: parameters ?? {}, }, }), - }, { - ...remoteHttpFetchOptions(), - // This call site owns a caller-set budget that can exceed the - // transport's default response deadline, so hand it down rather than - // letting the tighter default cut a legitimately slow tool short. - responseTimeoutMs: ms, - }); + }; + const response = options.remoteHttpRequest + ? await options.remoteHttpRequest(endpoint, requestInit) + : await guardedRemoteHttpFetch(endpoint, requestInit, { + ...remoteHttpFetchOptions(), + // This call site owns a caller-set budget that can exceed the + // transport's default response deadline, so hand it down rather than + // letting the tighter default cut a legitimately slow tool short. + responseTimeoutMs: ms, + }); const body = await readBoundedRemoteResponse(response); execution.response = { httpStatus: response.status, diff --git a/server/src/services/workspace-runtime-exposure.test.ts b/server/src/services/workspace-runtime-exposure.test.ts index 9f674982b7..ca145c5fa6 100644 --- a/server/src/services/workspace-runtime-exposure.test.ts +++ b/server/src/services/workspace-runtime-exposure.test.ts @@ -354,6 +354,7 @@ function startInput(options?: { services: [{ name: options?.serviceName ?? "preview", command: options?.command ?? serviceCommand(), + env: { PAPERCLIP_PUBLIC_URL: "http://127.0.0.1:3100" }, port: options?.port ?? { type: "auto", envKey: "PORT" }, readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 5 }, ...(expose ? { expose } : {}), diff --git a/server/src/services/workspace-runtime-read-model.test.ts b/server/src/services/workspace-runtime-read-model.test.ts index 76ad542aad..9f90063313 100644 --- a/server/src/services/workspace-runtime-read-model.test.ts +++ b/server/src/services/workspace-runtime-read-model.test.ts @@ -146,4 +146,63 @@ describe("selectConfiguredRuntimeServiceRows", () => { }), ]); }); + + it("can fall back to legacy execution-workspace scope for directly configured services", () => { + const projectScopedHistory = runtimeServiceRow({ + serviceName: "worker", + command: "pnpm worker", + }); + const legacyExecutionScopedWeb = runtimeServiceRow({ + executionWorkspaceId: randomUUID(), + scopeType: "execution_workspace", + scopeId: randomUUID(), + serviceName: "web", + command: "pnpm dev", + }); + + const selected = selectConfiguredRuntimeServiceRows( + [projectScopedHistory, legacyExecutionScopedWeb], + { services: [{ name: "web", command: "pnpm dev" }] }, + { fallbackScopeTypes: ["execution_workspace"] }, + ); + + expect(selected).toEqual([ + expect.objectContaining({ + id: legacyExecutionScopedWeb.id, + configIndex: 0, + }), + ]); + }); + + it("selects the row for the configured port instead of newer history on another port", () => { + const previousPort = runtimeServiceRow({ + port: 42013, + updatedAt: new Date("2026-07-31T10:00:00.000Z"), + }); + const configuredPort = runtimeServiceRow({ + port: 42001, + updatedAt: new Date("2026-07-30T10:00:00.000Z"), + }); + + const selected = selectConfiguredRuntimeServiceRows( + [previousPort, configuredPort], + { + services: [ + { + name: "web", + command: "pnpm dev", + port: { type: "fixed", value: 42001 }, + }, + ], + }, + ); + + expect(selected).toEqual([ + expect.objectContaining({ + id: configuredPort.id, + port: 42001, + configIndex: 0, + }), + ]); + }); }); diff --git a/server/src/services/workspace-runtime-read-model.ts b/server/src/services/workspace-runtime-read-model.ts index 471211fa43..0f2bf584ea 100644 --- a/server/src/services/workspace-runtime-read-model.ts +++ b/server/src/services/workspace-runtime-read-model.ts @@ -34,6 +34,9 @@ export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow export function selectConfiguredRuntimeServiceRows( rows: WorkspaceRuntimeServiceRow[], workspaceRuntime: Record | null | undefined, + options?: { + fallbackScopeTypes?: WorkspaceRuntimeServiceRow["scopeType"][]; + }, ) { const availableRows = selectCurrentRuntimeServiceRows(rows).map((row) => ({ ...row, @@ -52,9 +55,18 @@ export function selectConfiguredRuntimeServiceRows( : command.lifecycle === "shared" ? "project_workspace" : "run"; - const matchedRow = matchWorkspaceRuntimeServiceToCommand( - command, - availableRows.filter((row) => row.scopeType === expectedScope), + const candidateScopes = [ + expectedScope, + ...(options?.fallbackScopeTypes ?? []).filter((scopeType) => scopeType !== expectedScope), + ]; + const matchedRow = candidateScopes.reduce<(typeof availableRows)[number] | null>( + (match, scopeType) => + match + ?? matchWorkspaceRuntimeServiceToCommand( + command, + availableRows.filter((row) => row.scopeType === scopeType), + ), + null, ); if (!matchedRow) continue; selectedRows.push({ diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 0d1a504765..71222e50af 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -681,6 +681,11 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ delete env[key]; } } + // These origin settings belong to the parent instance. Letting them leak into a + // managed worktree runtime can send auth cookies and OAuth callbacks to the wrong + // Paperclip instance. Runtime/service overrides are merged back after sanitizing. + delete env.BETTER_AUTH_URL; + delete env.BETTER_AUTH_BASE_URL; delete env.DATABASE_URL; delete env.npm_config_tailscale_auth; delete env.npm_config_authenticated_private; @@ -5184,6 +5189,151 @@ function isPaperclipDevRuntimeService(input: { serviceName?: string | null; comm ); } +export const MANAGED_RUNTIME_PUBLIC_URL_ENV = "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL"; + +const EXPLICIT_RUNTIME_ORIGIN_ENV_KEYS = [ + "PAPERCLIP_PUBLIC_URL", + "PAPERCLIP_AUTH_PUBLIC_BASE_URL", + "BETTER_AUTH_URL", + "BETTER_AUTH_BASE_URL", +] as const; + +function isLoopbackRuntimeHostname(hostname: string) { + const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1") return true; + if (net.isIP(normalized) !== 4) return false; + const firstOctet = Number(normalized.split(".")[0]); + return firstOctet === 127; +} + +function managedRuntimeOriginError(serviceName: string, reason: string) { + return new Error( + `Runtime service "${serviceName}" cannot derive a browser-reachable OAuth callback origin: ${reason}. ` + + "Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL for this service, or publish an HTTPS expose.urlTemplate " + + "that the operator's browser can reach (loopback HTTP is also supported).", + ); +} + +type TrustedRuntimeHostnameBoundary = + | { exactHostname: string; hostnameSuffix?: never } + | { exactHostname?: never; hostnameSuffix: string }; + +function trustedRuntimeHostnameBoundary( + urlTemplate: string | null | undefined, +): TrustedRuntimeHostnameBoundary | null { + if (!urlTemplate?.trim()) return null; + let markerIndex = 0; + const markerPrefix = "paperclip-runtime-template-"; + const safeTemplate = urlTemplate.replace( + /{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, + (_match, path: string) => path === "port" ? "443" : `${markerPrefix}${markerIndex++}`, + ); + let parsed: URL; + try { + parsed = new URL(safeTemplate); + } catch { + return null; + } + if (parsed.username || parsed.password) return null; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + + const hostname = parsed.hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + const markers = [...hostname.matchAll(/paperclip-runtime-template-\d+/g)]; + const lastMarker = markers.at(-1); + if (!lastMarker || lastMarker.index === undefined) { + return hostname ? { exactHostname: hostname } : null; + } + + const hostnameSuffix = hostname.slice(lastMarker.index + lastMarker[0].length); + // A dynamic non-loopback hostname needs at least a stable two-label domain + // after the final interpolation. This binds rendered branch/workspace values + // to the operator-configured domain instead of trusting URL parsing alone. + if (!hostnameSuffix.startsWith(".") || !hostnameSuffix.slice(1).includes(".")) return null; + return { hostnameSuffix }; +} + +/** + * Resolve the low-priority public URL hint injected into a managed Paperclip dev + * service. Explicit operator origin settings are deliberately left untouched. + */ +export function resolveManagedPaperclipRuntimePublicOrigin(input: { + serviceName: string; + command: string; + environment: Record; + exposedUrl: string | null; + exposedUrlTemplate?: string | null; +}) { + if (!isPaperclipDevRuntimeService(input)) return null; + if (EXPLICIT_RUNTIME_ORIGIN_ENV_KEYS.some((key) => input.environment[key]?.trim())) return null; + if (!input.exposedUrl) { + throw managedRuntimeOriginError(input.serviceName, "the managed service does not report an exposed URL"); + } + + let parsed: URL; + try { + parsed = new URL(input.exposedUrl); + } catch { + throw managedRuntimeOriginError(input.serviceName, "the managed service reports an invalid exposed URL"); + } + + if (parsed.username || parsed.password) { + throw managedRuntimeOriginError(input.serviceName, "the exposed URL contains credentials"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw managedRuntimeOriginError(input.serviceName, "the exposed URL must use HTTP or HTTPS"); + } + + const hostname = parsed.hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + const loopback = isLoopbackRuntimeHostname(hostname); + if (!hostname || hostname === "0.0.0.0" || hostname === "::") { + throw managedRuntimeOriginError(input.serviceName, "the exposed URL uses a bind-only hostname"); + } + if ( + !loopback + && ( + (!hostname.includes(".") && net.isIP(hostname) === 0) + || hostname.endsWith(".invalid") + || hostname.endsWith(".test") + || hostname.endsWith(".internal") + || hostname.endsWith(".localdomain") + || hostname === "example.com" + || hostname.endsWith(".example.com") + || hostname === "example.net" + || hostname.endsWith(".example.net") + || hostname === "example.org" + || hostname.endsWith(".example.org") + ) + ) { + throw managedRuntimeOriginError( + input.serviceName, + `the exposed hostname "${hostname}" is internal-only or non-resolvable from a normal browser`, + ); + } + if (!loopback && parsed.protocol !== "https:") { + throw managedRuntimeOriginError(input.serviceName, "non-loopback OAuth callbacks require HTTPS"); + } + if (!loopback) { + const boundary = trustedRuntimeHostnameBoundary(input.exposedUrlTemplate); + if (!boundary) { + throw managedRuntimeOriginError( + input.serviceName, + "the exposed URL template does not define a stable hostname boundary", + ); + } + const withinBoundary = "exactHostname" in boundary + ? hostname === boundary.exactHostname + : hostname.length > boundary.hostnameSuffix.length && hostname.endsWith(boundary.hostnameSuffix); + if (!withinBoundary) { + throw managedRuntimeOriginError( + input.serviceName, + `the exposed hostname "${hostname}" is outside the hostname boundary configured by expose.urlTemplate`, + ); + } + } + + return parsed.origin; +} + function resolveRuntimeServiceHealthUrl( url: string | null, input?: { serviceName?: string | null; command?: string | null }, @@ -5958,13 +6108,14 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P port === identityPort ? identity.serviceCwd : resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd); + const runtimeEnvOverrides: Record = { ...input.adapterEnv }; + for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) { + runtimeEnvOverrides[key] = value; + } const env: Record = { ...sanitizeRuntimeServiceBaseEnv(process.env), - ...input.adapterEnv, + ...runtimeEnvOverrides, } as Record; - for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) { - env[key] = value; - } if (port) { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port); @@ -6013,6 +6164,20 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P let url = exposureConfig ? null : backendUrl; const readinessUrlTemplate = asString(readiness.urlTemplate, ""); const readinessUrl = readinessUrlTemplate ? renderTemplate(readinessUrlTemplate, templateData) : null; + const managedRuntimePublicOrigin = resolveManagedPaperclipRuntimePublicOrigin({ + serviceName, + command, + // Includes the trusted public origin injected above for managed HTTPS + // exposure. The inherited parent environment was already sanitized, so + // any remaining explicit origin is either service-configured or broker- + // derived for this exact runtime. + environment: env, + exposedUrl: url, + exposedUrlTemplate: urlTemplate, + }); + if (managedRuntimePublicOrigin) { + env[MANAGED_RUNTIME_PUBLIC_URL_ENV] = managedRuntimePublicOrigin; + } const stopPolicy = parseObject(input.service.stopPolicy); const serviceKey = createLocalServiceKey({ profileKind: "workspace-runtime", @@ -7328,6 +7493,7 @@ type StartRuntimeServicesForWorkspaceControlInput = { onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; recorder?: WorkspaceOperationRecorder | null; serviceIndex?: number | null; + runtimeServiceId?: string | null; respectDesiredStates?: boolean; }; @@ -7359,6 +7525,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( const refs: RuntimeServiceRef[] = []; const pendingReadiness: PendingRuntimeServiceReadiness[] = []; const startedServiceIds: string[] = []; + const requestedRuntimeServiceId = rawServices.length === 1 ? input.runtimeServiceId : null; for (const service of rawServices) { const { scopeType, scopeId } = resolveServiceScopeId({ @@ -7381,7 +7548,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( if (reuseKey) { const existing = await findHealthyRunningRuntimeService(reuseKey); - if (existing) { + if (existing && (!requestedRuntimeServiceId || existing.id === requestedRuntimeServiceId)) { const prepared = options?.preparedProvisioning; if (prepared?.service === service && prepared.record.id !== existing.id && persistenceDb) { await persistenceDb @@ -7423,6 +7590,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( : undefined, allowFixedPortFallback: options?.allowFixedPortFallback, excludedPorts: options?.excludedPorts, + runtimeServiceId: requestedRuntimeServiceId ?? undefined, reuseKey, scopeType, scopeId, diff --git a/tests/e2e/application-delete-screenshot.spec.ts b/tests/e2e/application-delete-screenshot.spec.ts index 0202971fcd..1bdb468362 100644 --- a/tests/e2e/application-delete-screenshot.spec.ts +++ b/tests/e2e/application-delete-screenshot.spec.ts @@ -33,7 +33,7 @@ test("captures the current app removal confirmations", async ({ page }) => { applicationName: "Guarded MCP", name: "Primary connection", transport: "mcp_remote", - config: { url: "https://fixture.example/mcp" }, + config: { url: "http://127.0.0.1:65535/mcp" }, }, }); expect(conn.ok(), `connection create failed ${conn.status()}: ${await conn.text()}`).toBe(true); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index fc0697849a..da059b6181 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -46,7 +46,7 @@ async function createConnection( const res = await request.post(`/api/companies/${companyId}/tools/connections`, { data: { transport: "mcp_remote", - config: { url: "https://fixture.example/mcp" }, + config: { url: "http://127.0.0.1:65535/mcp" }, enabled: true, status: "active", ...data, @@ -84,9 +84,9 @@ test.describe.serial("applications lifecycle", () => { await gotoApps(page, seed.prefix); - // The connected app starts with a "Healthy" pill and an "Open" action. A + // The connected app starts with a "Healthy" pill and an "Edit" action. A // background health sweep then probes the connection endpoint. The test - // endpoint is an unreachable fixture URL, so the probe fails and the pill + // endpoint is an unreachable loopback URL, so the probe fails and the pill // becomes "Needs attention" and the action becomes "Reconnect". Both are // connected states that navigate to the same provider setup page. This test // proves the connected-vs-not-connected split, not the transient health @@ -96,7 +96,7 @@ test.describe.serial("applications lifecycle", () => { const connectedRow = page.locator("tbody tr", { hasText: connectedName }); await expect(connectedRow).toBeVisible(); await expect(connectedRow.getByText(/^(Healthy|Needs attention)$/)).toBeVisible({ timeout: 30_000 }); - await expect(connectedRow.getByRole("button", { name: /^(Open|Reconnect)$/ })).toBeVisible(); + await expect(connectedRow.getByRole("button", { name: /^(Edit|Reconnect)$/ })).toBeVisible(); // The not-connected app has no connection, so the health sweep never touches // it and its "Not connected" pill and "Connect" action stay deterministic. @@ -106,9 +106,9 @@ test.describe.serial("applications lifecycle", () => { await expect(notConnectedRow.getByRole("button", { name: "Connect" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-list.png`, fullPage: true }); - await connectedRow.getByRole("button", { name: /^(Open|Reconnect)$/ }).click(); + await connectedRow.getByRole("button", { name: /^(Edit|Reconnect)$/ }).click(); await expect(page).toHaveURL( - new RegExp(`/${seed.prefix}/apps/app/${connected.applicationId}/setup$`), + new RegExp(`/${seed.prefix}/apps/${connected.id}/setup$`), { timeout: 20_000 }, ); diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index 18937694a4..d43a4bd38a 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -137,7 +137,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { await forceDark(page); await page.goto(`/${seed.prefix}/apps/connections`); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText(/app needs attention/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/connection needs attention/i).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-02-attention-dark.png`, fullPage: true }); }); @@ -160,9 +160,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { test("developer tabs share the merged Apps sidebar", async ({ page }) => { await forceDark(page); await page.goto(`/${seed.prefix}/apps/advanced/profiles`); - await expect(page.getByRole("heading", { name: "Developer tools" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("heading", { name: "Access profiles" })).toBeVisible({ timeout: 30_000 }); - await expect(page.locator('a[href$="/apps/advanced/runtime"]', { hasText: "Health" })).toBeVisible(); await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible(); await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0); // Apps section lives in the same sidebar now. diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts index aeef995fd1..4935f0d885 100644 --- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts +++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts @@ -151,7 +151,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { await gotoConnect(page, seed.prefix); // Browse launches the BYO link-mode connect wizard. - await expect(page.getByRole("heading", { name: "Connect an app" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Connect your own MCP server", { exact: true })).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-01-gallery.png`, fullPage: true }); // Use the "Connect with a link" path against the mock MCP server. @@ -226,7 +226,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { // Needs-attention page should surface this connection. await gotoNeedsAttention(page, seed.prefix); await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText(/app needs attention/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/connection needs attention/i).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-07-needs-attention.png`, fullPage: true }); // App detail should expose the reconnect call-to-action. diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 221c07ec5f..56bf0c032a 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -249,8 +249,15 @@ describe("Apps routes", () => { expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + }); + + it("redirects legacy Rules and Health links to the remaining developer surfaces", () => { + expect(appSource).toContain('if (tab === "runtime") return "/apps/connections";'); + expect(appSource).toContain('if (tab === "policies") return "/apps/advanced/profiles";'); }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b507a08f5d..e1184a6452 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -156,6 +156,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -166,6 +167,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -445,6 +447,8 @@ function LegacyToolsRedirect() { function legacyToolsRedirectTarget(tab?: string) { if (!tab) return "/apps/advanced/profiles"; if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections"; + if (tab === "runtime") return "/apps/connections"; + if (tab === "policies") return "/apps/advanced/profiles"; return `/apps/advanced/${tab}`; } diff --git a/ui/src/api/tools.test.ts b/ui/src/api/tools.test.ts new file mode 100644 index 0000000000..4d0a3fb0d3 --- /dev/null +++ b/ui/src/api/tools.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { toolsApi } from "./tools"; + +describe("toolsApi.listActivity", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue({ events: [], nextCursor: null }); + }); + + it("uses the omitted-window contract for all-time activity", async () => { + await toolsApi.listActivity("company-1", { window: "all", limit: 50 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/tool-gateway/audit?companyId=company-1&limit=50", + ); + }); + + it("sends bounded activity windows explicitly", async () => { + await toolsApi.listActivity("company-1", { window: "30d", limit: 50 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/tool-gateway/audit?companyId=company-1&window=30d&limit=50", + ); + }); +}); diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index acc06d4e43..a6bc260e4c 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -38,6 +38,7 @@ import type { AppDefinition, ToolAppsAttentionResponse, ToolConnectionActivityResponse, + ToolConnectionLifecycleEventType, ToolConnectionTestAgentsResponse, ToolConnectionTestCallResult, ToolConnectionTestCallStatus, @@ -51,6 +52,7 @@ import type { CreateToolMcpGatewayToken, UpdateToolMcpGateway, CreateToolTrustRuleFromActionRequest, + ToolRedactedValueSummary, } from "@paperclipai/shared"; import { api } from "./client"; @@ -202,11 +204,27 @@ export interface ToolGatewayActivityEvent extends ToolGatewayAuditRow { applicationId: string | null; connectionId: string | null; agentDisplayName: string | null; + actorDisplayName?: string | null; appDisplayName: string | null; applicationDisplayName: string | null; connectionDisplayName: string | null; toolDisplayName: string | null; + lifecycleType?: ToolConnectionLifecycleEventType | null; normalizedOutcome: ToolAuditOutcome; + invocation: { + id: string; + toolName: string; + status: string; + policyDecision: string | null; + approvalState: string; + argumentsSummary: ToolRedactedValueSummary | null; + resultSummary: ToolRedactedValueSummary | null; + resultSizeBytes: number | null; + errorCode: string | null; + errorMessage: string | null; + startedAt: string | null; + completedAt: string | null; + } | null; } export type ToolGatewayActivityResponse = { @@ -214,9 +232,10 @@ export type ToolGatewayActivityResponse = { nextCursor: string | null; }; -export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d"; +export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d" | "all"; export interface ListActivityParams { + gateway?: string | null; app?: string | null; agent?: string | null; outcome?: string | null; @@ -447,10 +466,14 @@ export const toolsApi = { */ listActivity: (companyId: string, params: ListActivityParams = {}) => { const search = new URLSearchParams({ companyId }); + if (params.gateway) search.set("gateway", params.gateway); if (params.app) search.set("app", params.app); if (params.agent) search.set("agent", params.agent); if (params.outcome) search.set("outcome", params.outcome); - if (params.window) search.set("window", params.window); + // Omitting the window is the API's canonical all-time request. This also + // keeps the page usable during a rolling restart against an older server + // that does not recognize the newer explicit `all` value. + if (params.window && params.window !== "all") search.set("window", params.window); if (params.search) search.set("search", params.search); if (params.cursor) search.set("cursor", params.cursor); search.set("limit", String(params.limit ?? 50)); diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx index dd4da073b2..bf3a44a59d 100644 --- a/ui/src/components/AppConnectionSidebar.test.tsx +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -164,18 +164,18 @@ describe("AppConnectionSidebar", () => { await flushReact(); } - it("renders a back link and the connected app tabs (including Test)", async () => { + it("renders a back link and the connected app tabs with Test after Setup", async () => { await renderSidebar(); expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); expect(container.textContent).toContain("GitHub"); - expect(container.querySelectorAll("[data-to]").length).toBe(6); + expect(container.querySelectorAll("[data-to]").length).toBe(5); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 3, badgeTone: "danger" })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/test", label: "Test", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/activity", label: "Activity", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/advanced", label: "Advanced", end: true })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" })); }); it("marks the current tab active through the nav item target", async () => { @@ -197,11 +197,11 @@ describe("AppConnectionSidebar", () => { expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/review", label: "Review", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/permissions", label: "Permissions", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/activity", label: "Activity", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/advanced", label: "Advanced", end: true })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" })); expect(container.querySelector('[data-to="/apps/app/app-1/review"]')?.getAttribute("data-active")).toBe("true"); // The Test tab needs a live connection, so it is hidden in application mode. expect(container.querySelector('[data-to="/apps/app/app-1/test"]')).toBeNull(); - expect(container.querySelectorAll("[data-to]").length).toBe(5); + expect(container.querySelectorAll("[data-to]").length).toBe(4); }); it("keeps rendering a connection sidebar when its connection is unavailable", async () => { @@ -213,7 +213,7 @@ describe("AppConnectionSidebar", () => { expect(container.textContent).toContain("App"); expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); - expect(container.querySelectorAll("[data-to]").length).toBe(6); + expect(container.querySelectorAll("[data-to]").length).toBe(5); }); it("keeps rendering an application sidebar when its application is unavailable", async () => { @@ -226,6 +226,6 @@ describe("AppConnectionSidebar", () => { expect(container.textContent).toContain("App"); expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); - expect(container.querySelectorAll("[data-to]").length).toBe(5); + expect(container.querySelectorAll("[data-to]").length).toBe(4); }); }); diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx index ff0c9e403b..45e3c6621a 100644 --- a/ui/src/components/AppsSidebar.test.tsx +++ b/ui/src/components/AppsSidebar.test.tsx @@ -8,7 +8,6 @@ import { AppsSidebar } from "./AppsSidebar"; const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockToolsApi = vi.hoisted(() => ({ - listRuntimeSlots: vi.fn(), listActionRequests: vi.fn(), })); @@ -89,12 +88,6 @@ describe("AppsSidebar", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); - mockToolsApi.listRuntimeSlots.mockResolvedValue({ - runtimeSlots: [ - { id: "slot-1", status: "running" }, - { id: "slot-2", status: "stopped" }, - ], - }); mockToolsApi.listActionRequests.mockResolvedValue({ actionRequests: [] }); }); @@ -123,6 +116,8 @@ describe("AppsSidebar", () => { expect(container.textContent).toContain("Developer"); // The Developer boundary caption frames who the door is for (PAP-13241 §5). expect(container.textContent).toContain("Advanced setup for developers"); + expect(container.textContent).not.toContain("Most teams"); + expect(container.textContent).not.toMatch(/you (?:won'?t|will not) need this/i); // "Run your own" / "Paste a config" moved to the Connect-an-app page (PAP-10922); // assert their absence at the item level below. @@ -154,9 +149,8 @@ describe("AppsSidebar", () => { expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles", end: true }), ); - expect(sidebarNavItemMock).toHaveBeenCalledWith( - expect.objectContaining({ to: "/apps/advanced/runtime", label: "Health", end: true, liveCount: 1 }), - ); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Health" })); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ to: "/apps/advanced/audit", label: "Activity", end: true }), ); diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index 882e969b7a..bca01e789a 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -1,10 +1,7 @@ import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; -import { queryKeys } from "@/lib/queryKeys"; -import { toolsApi } from "@/api/tools"; import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; import { useReviewCount } from "@/pages/apps/useReviewCount"; @@ -15,7 +12,7 @@ import { SidebarNavItem } from "./SidebarNavItem"; * PAP-13254 / U3). * * ← Back · APPS: Browse / Review (n) - * DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity + * DEVELOPER: Connections / Gateways / Profiles / Activity * * "Browse" is the store and "Review" holds decisions waiting on the user's * OK. Connection management lives with the Developer tools. @@ -28,7 +25,7 @@ import { SidebarNavItem } from "./SidebarNavItem"; * (PAP-10922). */ export function AppsSidebar() { - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompany } = useCompany(); const { isMobile, setSidebarOpen } = useSidebar(); const reviewCount = useReviewCount(); @@ -37,15 +34,6 @@ export function AppsSidebar() { (tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled, ); - const runtimeSlots = useQuery({ - queryKey: queryKeys.tools.runtimeSlots(selectedCompanyId ?? "__none__"), - queryFn: () => toolsApi.listRuntimeSlots(selectedCompanyId!), - enabled: !!selectedCompanyId, - refetchInterval: 15_000, - }); - const runtimeActiveCount = (runtimeSlots.data?.runtimeSlots ?? []) - .filter((slot) => slot.status === "running").length; - return (