diff --git a/doc/screenshots/garden-mcp-split-stack/policies.png b/doc/screenshots/garden-mcp-split-stack/policies.png new file mode 100644 index 0000000000..5a4377af55 Binary files /dev/null and b/doc/screenshots/garden-mcp-split-stack/policies.png differ diff --git a/doc/screenshots/garden-mcp-split-stack/profile-wizard.png b/doc/screenshots/garden-mcp-split-stack/profile-wizard.png new file mode 100644 index 0000000000..112a1f88e7 Binary files /dev/null and b/doc/screenshots/garden-mcp-split-stack/profile-wizard.png differ diff --git a/doc/screenshots/garden-mcp-split-stack/profiles-index.png b/doc/screenshots/garden-mcp-split-stack/profiles-index.png new file mode 100644 index 0000000000..53bed4af38 Binary files /dev/null and b/doc/screenshots/garden-mcp-split-stack/profiles-index.png differ diff --git a/ui/src/adapters/use-disabled-adapters.test.tsx b/ui/src/adapters/use-disabled-adapters.test.tsx new file mode 100644 index 0000000000..c31d1eba4e --- /dev/null +++ b/ui/src/adapters/use-disabled-adapters.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDisabledAdaptersSync } from "./use-disabled-adapters"; + +const mockAdaptersApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("@/api/adapters", () => ({ + adaptersApi: mockAdaptersApi, +})); + +function Probe({ enabled }: { enabled: boolean }) { + useDisabledAdaptersSync({ enabled }); + return null; +} + +describe("useDisabledAdaptersSync", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + }); + + afterEach(() => { + flushSync(() => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + vi.clearAllMocks(); + }); + + it("does not fetch adapters when disabled", () => { + flushSync(() => { + root.render( + + + , + ); + }); + + expect(mockAdaptersApi.list).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts index ebc63946cd..d3140fed6d 100644 --- a/ui/src/adapters/use-disabled-adapters.ts +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -16,10 +16,12 @@ import { queryKeys } from "@/lib/queryKeys"; * Returns a reactive Set of disabled types for use as useMemo dependencies. * Call this at the top of any component that renders adapter menus. */ -export function useDisabledAdaptersSync(): Set { +export function useDisabledAdaptersSync(options: { enabled?: boolean } = {}): Set { + const enabled = options.enabled ?? true; const { data: adapters } = useQuery({ queryKey: queryKeys.adapters.all, queryFn: () => adaptersApi.list(), + enabled, staleTime: 5 * 60 * 1000, }); diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index b0da51651a..8c59e37d96 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -5,6 +5,7 @@ import { type CurrentUserProfile, type UpdateCurrentUserProfile, } from "@paperclipai/shared"; +import { redactUrlSecrets } from "@/lib/redact-url-secrets"; type AuthErrorBody = | { @@ -60,15 +61,66 @@ function extractAuthError(payload: AuthErrorBody, status: number) { return new AuthApiError(message, status, payload, code); } -async function authPost(path: string, body: Record) { - const res = await fetch(`/api/auth${path}`, { - method: "POST", +// Rich diagnostics for auth requests. Network-layer failures (Safari +// "Load failed" / Chrome "Failed to fetch") throw a TypeError *before* any +// HTTP response, so they are indistinguishable from a bad password in the UI +// unless we log the resolved request URL + origin here. See PAP-13466. +function resolveAuthUrl(path: string) { + const relative = `/api/auth${path}`; + try { + return new URL(relative, window.location.origin).href; + } catch { + return relative; + } +} + +function logAuthNetworkFailure(method: string, path: string, error: unknown) { + // eslint-disable-next-line no-console + console.error("[auth] request failed at the network layer (no HTTP response)", { + method, + requestUrl: resolveAuthUrl(path), + pageOrigin: typeof window !== "undefined" ? window.location.origin : "(no window)", + pageHref: typeof window !== "undefined" ? redactUrlSecrets(window.location.href) : "(no window)", credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + online: typeof navigator !== "undefined" ? navigator.onLine : "(no navigator)", + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + error, + hint: + "This means the browser never got a response from the server. Common causes: " + + "the page origin differs from the API host (mixed http/https, wrong hostname/port, " + + "or a proxy/tunnel that only forwards the page but not /api), an SSL error, or the " + + "connection was reset. A wrong password would instead return HTTP 401, not this.", }); +} + +function logAuthHttpError(method: string, path: string, status: number, statusText: string, body: unknown) { + // eslint-disable-next-line no-console + console.error("[auth] request returned an error status", { + method, + requestUrl: resolveAuthUrl(path), + status, + statusText, + body, + }); +} + +async function authPost(path: string, body: Record) { + let res: Response; + try { + res = await fetch(`/api/auth${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (networkError) { + logAuthNetworkFailure("POST", path, networkError); + throw networkError; + } const payload = await res.json().catch(() => null); if (!res.ok) { + logAuthHttpError("POST", path, res.status, res.statusText, payload); throw extractAuthError(payload as AuthErrorBody, res.status); } return payload; diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 2a27addedf..a90efb8fc1 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -139,6 +139,10 @@ export function __inflightGetCount(): number { return inflightGets.size; } +function isRequestOptions(value: unknown): value is RequestOptions { + return typeof value === "object" && value !== null && "signal" in value; +} + export const api = { get: (path: string, options?: RequestOptions) => coalescedGet(path, options), post: (path: string, body: unknown, options?: RequestOptions) => @@ -149,8 +153,11 @@ export const api = { request(path, { method: "PUT", body: JSON.stringify(body), signal: options?.signal }), patch: (path: string, body: unknown, options?: RequestOptions) => request(path, { method: "PATCH", body: JSON.stringify(body), signal: options?.signal }), - delete: (path: string, options?: RequestOptions) => - request(path, { method: "DELETE", signal: options?.signal }), + delete: (path: string, bodyOrOptions?: unknown, options?: RequestOptions) => { + const requestOptions = isRequestOptions(bodyOrOptions) ? bodyOrOptions : options; + const body = bodyOrOptions === undefined || isRequestOptions(bodyOrOptions) ? undefined : JSON.stringify(bodyOrOptions); + return request(path, { method: "DELETE", ...(body === undefined ? {} : { body }), signal: requestOptions?.signal }); + }, deleteWithBody: (path: string, body: unknown, options?: RequestOptions) => request(path, { method: "DELETE", body: JSON.stringify(body), signal: options?.signal }), }; diff --git a/ui/src/api/companies-query.test.ts b/ui/src/api/companies-query.test.ts new file mode 100644 index 0000000000..2d114d39a5 --- /dev/null +++ b/ui/src/api/companies-query.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { companiesListQueryOptions } from "./companies-query"; +import { ApiError } from "./client"; + +const mockCompaniesApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("./companies", () => ({ + companiesApi: mockCompaniesApi, +})); + +describe("companiesListQueryOptions", () => { + it.each([401, 403])("treats %s company-list failures as unauthorized bootstrap state", async (status) => { + mockCompaniesApi.list.mockRejectedValueOnce(new ApiError("Board access required", status, { error: "Board access required" })); + + await expect(companiesListQueryOptions.queryFn()).resolves.toEqual({ + companies: [], + unauthorized: true, + }); + }); +}); diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts index c01c481a93..4250193d04 100644 --- a/ui/src/api/companies-query.ts +++ b/ui/src/api/companies-query.ts @@ -15,7 +15,7 @@ export const companiesListQueryOptions = { try { return { companies: await companiesApi.list(), unauthorized: false }; } catch (err) { - if (err instanceof ApiError && err.status === 401) { + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { return { companies: [], unauthorized: true }; } throw err; diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 0b32cfd845..412f87fe96 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -358,8 +358,8 @@ export const pluginsApi = { * * @param pluginId - UUID of the plugin. */ - getConfig: (pluginId: string) => - api.get(`/plugins/${pluginId}/config`), + getConfig: (pluginId: string, companyId: string) => + api.get(`/plugins/${pluginId}/config?companyId=${encodeURIComponent(companyId)}`), /** * Save (create or update) the configuration for a plugin. @@ -370,8 +370,8 @@ export const pluginsApi = { * @param pluginId - UUID of the plugin. * @param configJson - Configuration values matching the plugin's `instanceConfigSchema`. */ - saveConfig: (pluginId: string, configJson: Record) => - api.post(`/plugins/${pluginId}/config`, { configJson }), + saveConfig: (pluginId: string, companyId: string, configJson: Record) => + api.post(`/plugins/${pluginId}/config`, { companyId, configJson }), /** * Call the plugin's `validateConfig` RPC method to test the configuration @@ -385,8 +385,8 @@ export const pluginsApi = { * @param pluginId - UUID of the plugin. * @param configJson - Configuration values to validate. */ - testConfig: (pluginId: string, configJson: Record) => - api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }), + testConfig: (pluginId: string, companyId: string, configJson: Record) => + api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { companyId, configJson }), /** * List manifest-declared and stored company-scoped local folders for a plugin. diff --git a/ui/src/api/smokeLab.ts b/ui/src/api/smokeLab.ts new file mode 100644 index 0000000000..e5dd2410f7 --- /dev/null +++ b/ui/src/api/smokeLab.ts @@ -0,0 +1,68 @@ +import type { + CreateSmokeRun, + RecordSmokeRunStep, + SmokeLabServiceStatus, + SmokeRun, + SmokeRunStep, + UpdateSmokeRun, +} from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * Smoke Lab API client (PAP-13347 / S2, plan §D3). Mirrors the S1 results API + * shipped in `server/src/routes/smoke-lab.ts` (PAP-13346). Every endpoint is + * company-scoped and gated server-side on `experimental.enableSmokeLab`, + * `deploymentMode === local_trusted`, and a non-production environment — the UI + * only ever surfaces these screens when the board-readable experimental flag is + * on, but the server stays authoritative. + */ + +export interface SmokeLabServicesResponse { + services: SmokeLabServiceStatus[]; +} + +export interface SmokeLabInstallFixturesResponse { + created: boolean; + applications: Array<{ id: string; name: string }>; + connections: Array<{ id: string; name: string }>; + catalog: unknown[]; + profile: { id: string }; +} + +export interface SmokeLabRunsResponse { + runs: SmokeRun[]; +} + +export interface SmokeLabRunDetailResponse { + run: SmokeRun; + steps: SmokeRunStep[]; +} + +const base = (companyId: string) => `/companies/${companyId}/smoke-lab`; + +export const smokeLabApi = { + listServices: (companyId: string) => + api.get(`${base(companyId)}/services`), + startServices: (companyId: string) => + api.post(`${base(companyId)}/services/start`, {}), + stopServices: (companyId: string) => + api.post(`${base(companyId)}/services/stop`, {}), + installFixtures: (companyId: string) => + api.post(`${base(companyId)}/install-fixtures`, {}), + reset: (companyId: string) => + api.post<{ reset: boolean }>(`${base(companyId)}/reset`, {}), + + listRuns: (companyId: string) => + api.get(`${base(companyId)}/runs`), + getRun: (companyId: string, runId: string) => + api.get(`${base(companyId)}/runs/${runId}`), + createRun: (companyId: string, input: CreateSmokeRun) => + api.post<{ run: SmokeRun }>(`${base(companyId)}/runs`, input), + updateRun: (companyId: string, runId: string, input: UpdateSmokeRun) => + api.patch<{ run: SmokeRun }>(`${base(companyId)}/runs/${runId}`, input), + recordStep: (companyId: string, runId: string, input: RecordSmokeRunStep) => + api.post<{ step: SmokeRunStep; summary: Record }>( + `${base(companyId)}/runs/${runId}/steps`, + input, + ), +}; diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts new file mode 100644 index 0000000000..3f93ac8cd2 --- /dev/null +++ b/ui/src/api/tools.ts @@ -0,0 +1,460 @@ +import type { + ToolApplication, + ToolConnection, + ToolConnectionInstall, + ToolConnectionInstallSnapshot, + ConnectToolAppResult, + FinishToolAppResult, + ToolCatalogEntry, + ToolRuntimeSlot, + ToolPolicy, + ToolConnectionHealthCheckResult, + ToolCatalogRefreshResult, + ToolAccessDecision, + ToolAccessDecisionInput, + CreateToolPolicy, + DuplicateToolPolicy, + McpJsonImportPreview, + ToolRuntimeHealthSummary, + ToolRunDecisionLookup, + ToolOAuthStartResult, + ToolStdioCommandTemplate, + ToolProfileBinding, + ToolProfileBindingTargetType, + ToolProfileDefaultAction, + ToolProfileEffectiveSummary, + ToolProfileEntry, + ToolProfileEntryEffect, + ToolProfileEntrySelectorType, + ToolProfileStatus, + ToolProfileWithDetails, + ToolProfileNewToolReviewDecision, + ToolProfileNewToolsReview, + ToolProfileNewToolsReviewResult, + ToolRiskLevel, + UpdateToolPolicy, + ReorderToolPolicies, + AppGalleryEntry, + ToolAppsAttentionResponse, + ToolConnectionActivityResponse, + ToolConnectionTestAgentsResponse, + ToolConnectionTestCallResult, + ToolConnectionTestCallStatus, + ToolActionRequest, + ToolActionRequestStatus, + ToolActionRequestsResponse, + ToolMcpGatewayWithTokens, + ToolMcpGatewayTokenCreated, + ToolMcpGatewayToken, + CreateToolMcpGateway, + CreateToolMcpGatewayToken, + UpdateToolMcpGateway, + CreateToolTrustRuleFromActionRequest, +} from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * Tools & Access API client (Phase 6, PAP-10389). + * + * Mirrors the governed MCP/tool-access contracts shipped by Phases 2-5 + * (`server/src/routes/tool-access.ts` and `tool-gateway.ts`). The UI consumes + * server-side enforcement contracts directly instead of faking tool access in + * the browser. + */ + +export type ToolApplicationsResponse = { applications: ToolApplication[] }; +export type ToolConnectionsResponse = { connections: ToolConnection[] }; +export type ToolCatalogResponse = { catalog: ToolCatalogEntry[] }; +export type ToolRuntimeSlotsResponse = { runtimeSlots: ToolRuntimeSlot[] }; +export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary; +export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] }; +export type ToolPoliciesResponse = { policies: ToolPolicy[] }; +export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] }; +export type ToolGalleryResponse = { apps: AppGalleryEntry[] }; +export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] }; +export type CreateGatewayTokenInput = Omit & { + expiresAt?: string | Date | null; +}; +/** Gateway update payload — `companyId` is injected by the client method. */ +export type UpdateGatewayInput = Omit; +export type ReviewNewToolsInput = { + decisions: Array<{ catalogEntryId: string; decision: ToolProfileNewToolReviewDecision }>; +}; + +export type StdioTemplateSummary = ToolStdioCommandTemplate; +export type StdioTemplatesResponse = { templates: StdioTemplateSummary[] }; + +/** Admin "run your own" command-template create input (M8b, PAP-10862). */ +export interface CreateStdioTemplateInput { + templateId: string; + name: string; + description?: string | null; + command: string; + args?: string[]; + envKeys?: string[]; +} + +export interface CreateToolApplicationInput { + name: string; + description?: string | null; + type: ToolApplication["type"]; + pluginId?: string | null; + metadata?: Record | null; +} + +export interface UpdateToolApplicationInput { + name?: string; + description?: string | null; + status?: ToolApplication["status"]; + metadata?: Record | null; +} + +export interface CreateToolConnectionInput { + applicationId?: string; + applicationName?: string; + name: string; + transport: NonNullable; + status?: ToolConnection["status"]; + config?: Record; + credentialRefs?: ToolConnection["credentialRefs"]; + enabled?: boolean; +} + +export interface UpdateToolConnectionInput { + name?: string; + status?: ToolConnection["status"]; + config?: Record; + transportConfig?: Record; + credentialRefs?: ToolConnection["credentialRefs"]; + enabled?: boolean; +} + +export interface ToolProfileEntryInput { + selectorType: ToolProfileEntrySelectorType; + effect?: ToolProfileEntryEffect; + applicationId?: string | null; + connectionId?: string | null; + catalogEntryId?: string | null; + toolName?: string | null; + riskLevel?: ToolRiskLevel | null; + conditions?: Record | null; +} + +export interface CreateToolProfileInput { + profileKey: string; + name: string; + description?: string | null; + status?: ToolProfileStatus; + defaultAction?: ToolProfileDefaultAction; + metadata?: Record | null; + entries?: ToolProfileEntryInput[]; +} + +export interface UpdateToolProfileInput { + profileKey?: string; + name?: string; + description?: string | null; + status?: ToolProfileStatus; + defaultAction?: ToolProfileDefaultAction; + metadata?: Record | null; + entries?: ToolProfileEntryInput[]; +} + +export interface ToolProfileBindingInput { + targetType: ToolProfileBindingTargetType; + targetId: string; + priority?: number; + metadata?: Record | null; +} + +export type UnbindToolProfileInput = Pick; + +/** Redacted tool-gateway audit row (subset of `activity_log`). */ +export interface ToolGatewayAuditRow { + id: string; + companyId: string; + action: string; + actorType: string | null; + actorId: string | null; + entityType: string | null; + entityId: string | null; + details: Record | null; + createdAt: string; +} + +/** Normalized outcome for the humanized Activity feed. */ +export type ToolAuditOutcome = + | "allowed" + | "blocked" + | "asked_first" + | "waiting" + | "failed" + | "unknown"; + +/** + * Audit row enriched server-side with humanized display names and a normalized + * outcome — the shape returned by `GET /tool-gateway/audit`. + */ +export interface ToolGatewayActivityEvent extends ToolGatewayAuditRow { + agentId: string | null; + runId: string | null; + applicationId: string | null; + connectionId: string | null; + agentDisplayName: string | null; + appDisplayName: string | null; + applicationDisplayName: string | null; + connectionDisplayName: string | null; + toolDisplayName: string | null; + normalizedOutcome: ToolAuditOutcome; +} + +export type ToolGatewayActivityResponse = { + events: ToolGatewayActivityEvent[]; + nextCursor: string | null; +}; + +export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d"; + +export interface ListActivityParams { + app?: string | null; + agent?: string | null; + outcome?: string | null; + window?: ToolAuditWindow; + search?: string | null; + limit?: number; + cursor?: string | null; +} + +export type ToolPolicyTestResponse = { + decision: ToolAccessDecision; + auditEvent: unknown | null; +}; + +export const toolsApi = { + // --- Applications --- + listGallery: (companyId: string) => + api.get(`/companies/${companyId}/tools/gallery`), + connectApp: (companyId: string, input: { + galleryKey?: string; + link?: string; + name?: string; + credentialValues?: Record; + configValues?: Record; + applicationId?: string; + }) => + api.post(`/companies/${companyId}/tools/apps/connect`, input), + startOAuth: (connectionId: string) => + api.post(`/tools/oauth/${connectionId}/start`, {}), + finishApp: (companyId: string, connectionId: string, input: { + enabledCatalogEntryIds: string[]; + askFirstCatalogEntryIds: string[]; + access: "all_agents" | { agentIds: string[] }; + }) => + api.post( + `/companies/${companyId}/tools/apps/${connectionId}/finish`, + input, + ), + listAppsAttention: (companyId: string) => + api.get(`/companies/${companyId}/tools/apps/attention`), + listApplications: (companyId: string) => + api.get(`/companies/${companyId}/tools/applications`), + createApplication: (companyId: string, input: CreateToolApplicationInput) => + api.post(`/companies/${companyId}/tools/applications`, input), + updateApplication: (applicationId: string, input: UpdateToolApplicationInput) => + api.patch(`/tool-applications/${applicationId}`, input), + deleteApplication: (applicationId: string) => + api.delete(`/tool-applications/${applicationId}`), + + // --- Connections --- + listConnections: (companyId: string) => + api.get(`/companies/${companyId}/tools/connections`), + getConnection: (connectionId: string) => + api.get(`/tool-connections/${connectionId}`), + // --- Installs (Phase 3b, PAP-13618): which agents carry this connection's + // tools in their runtime context. `installed ⊆ permitted`; the server + // auto-extends access (adds profile bindings) for any newly-installed target. + getConnectionInstalls: (connectionId: string) => + api.get<{ connectionId: string; installs: ToolConnectionInstall[] }>( + `/tool-connections/${connectionId}/installs`, + ), + putConnectionInstalls: ( + connectionId: string, + installs: Array<{ targetType: "company" | "agent"; targetId: string }>, + ) => + api.put( + `/tool-connections/${connectionId}/installs`, + { installs }, + ), + createConnection: (companyId: string, input: CreateToolConnectionInput) => + api.post(`/companies/${companyId}/tools/connections`, input), + updateConnection: (connectionId: string, input: UpdateToolConnectionInput) => + api.patch(`/tool-connections/${connectionId}`, input), + archiveConnection: (connectionId: string) => + api.delete(`/tool-connections/${connectionId}`), + checkConnectionHealth: (connectionId: string) => + api.post(`/tool-connections/${connectionId}/health-check`, {}), + reconnectConnection: (connectionId: string, credentialValues: Record) => + api.post(`/tool-connections/${connectionId}/reconnect`, { + credentialValues, + }), + refreshCatalog: (connectionId: string) => + api.post(`/tool-connections/${connectionId}/catalog/refresh`, {}), + listCatalog: (connectionId: string) => + api.get(`/tool-connections/${connectionId}/catalog`), + listConnectionActivity: (connectionId: string, limit = 20) => + api.get( + `/tool-connections/${connectionId}/activity?limit=${limit}`, + ), + listTestAgents: (connectionId: string) => + api.get( + `/tool-connections/${connectionId}/test-agents`, + ), + runTestCall: ( + connectionId: string, + input: { agentId: string; toolName: string; parameters?: Record }, + ) => + api.post( + `/tool-connections/${connectionId}/test-calls`, + input, + ), + getTestCallStatus: (connectionId: string, actionRequestId: string) => + api.get( + `/tool-connections/${connectionId}/test-calls/${actionRequestId}`, + ), + importMcpJson: (companyId: string, body: { mcpJson: unknown }) => + api.post(`/companies/${companyId}/tools/mcp/import-json`, body), + listStdioTemplates: (companyId: string) => + api.get(`/companies/${companyId}/tools/stdio-templates`), + createStdioTemplate: (companyId: string, input: CreateStdioTemplateInput) => + api.post(`/companies/${companyId}/tools/stdio-templates`, input), + disableStdioTemplate: (companyId: string, templateId: string, reason?: string | null) => + api.post( + `/companies/${companyId}/tools/stdio-templates/${encodeURIComponent(templateId)}/disable`, + { reason: reason ?? null }, + ), + + // --- Profiles --- + listProfiles: (companyId: string) => + api.get(`/companies/${companyId}/tools/profiles`), + getProfileNewTools: (profileId: string) => + api.get(`/tool-profiles/${profileId}/new-tools`), + reviewProfileNewTools: (profileId: string, input: ReviewNewToolsInput) => + api.post(`/tool-profiles/${profileId}/new-tools/review`, input), + createProfile: (companyId: string, input: CreateToolProfileInput) => + api.post(`/companies/${companyId}/tools/profiles`, input), + updateProfile: (profileId: string, input: UpdateToolProfileInput) => + api.patch(`/tool-profiles/${profileId}`, input), + duplicateProfile: ( + profileId: string, + input: { name: string; includeAssignments?: boolean }, + ) => api.post(`/tool-profiles/${profileId}/duplicate`, input), + deleteProfile: ( + profileId: string, + input: { force?: boolean; reassignToProfileId?: string } = {}, + ) => api.delete<{ deleted: true }>(`/tool-profiles/${profileId}`, input), + addProfileEntry: (profileId: string, input: ToolProfileEntryInput) => + api.post(`/tool-profiles/${profileId}/entries`, input), + updateProfileEntry: (entryId: string, input: Partial) => + api.patch(`/tool-profile-entries/${entryId}`, input), + deleteProfileEntry: (entryId: string) => + api.delete(`/tool-profile-entries/${entryId}`), + bindProfile: (companyId: string, profileId: string, input: ToolProfileBindingInput) => + api.post(`/companies/${companyId}/tools/profiles/${profileId}/bind`, input), + unbindProfile: (companyId: string, profileId: string, input: UnbindToolProfileInput) => + api.post<{ unbound: number }>(`/companies/${companyId}/tools/profiles/${profileId}/unbind`, input), + getEffectiveProfilesForAgent: (companyId: string, agentId: string) => + api.get( + `/companies/${companyId}/tools/profiles/effective/agents/${encodeURIComponent(agentId)}`, + ), + + // --- Runtime --- + listRuntimeSlots: (companyId: string) => + api.get(`/companies/${companyId}/tools/runtime-slots`), + stopRuntimeSlot: (companyId: string, slotId: string) => + api.post(`/companies/${companyId}/tools/runtime-slots/${slotId}/stop`, {}), + restartRuntimeSlot: (companyId: string, slotId: string) => + api.post(`/companies/${companyId}/tools/runtime-slots/${slotId}/restart`, {}), + getRuntimeHealth: (companyId: string) => + api.get(`/companies/${companyId}/tools/runtime-health`), + getRunDecisionLookup: (companyId: string, runId: string) => + api.get(`/companies/${companyId}/tools/runs/${runId}/decisions`), + listLiveRuntimeSlots: (companyId: string) => + api.get(`/tool-gateway/runtime-slots?companyId=${encodeURIComponent(companyId)}`), + + // --- Policies (trust rules + decision simulator) --- + listPolicies: (companyId: string) => + api.get(`/companies/${companyId}/tools/policies`), + createPolicy: (companyId: string, input: CreateToolPolicy) => + api.post(`/companies/${companyId}/tools/policies`, input), + reorderPolicies: (companyId: string, input: ReorderToolPolicies) => + api.post(`/companies/${companyId}/tools/policies/reorder`, input), + duplicatePolicy: (companyId: string, policyId: string, input: DuplicateToolPolicy = {}) => + api.post(`/companies/${companyId}/tools/policies/${policyId}/duplicate`, input), + updatePolicy: (companyId: string, policyId: string, input: UpdateToolPolicy) => + api.patch(`/companies/${companyId}/tools/policies/${policyId}`, input), + deletePolicy: (companyId: string, policyId: string) => + api.delete(`/companies/${companyId}/tools/policies/${policyId}`), + listTrustRules: (companyId: string) => + api.get(`/companies/${companyId}/tools/trust-rules`), + revokeTrustRule: (companyId: string, policyId: string, reason?: string | null) => + api.post(`/companies/${companyId}/tools/trust-rules/${policyId}/revoke`, { + reason: reason ?? null, + }), + testPolicy: (companyId: string, input: Omit) => + api.post(`/companies/${companyId}/tools/policy/test`, input), + + // --- Review queue (Ask first) --- + listActionRequests: (companyId: string, status: ToolActionRequestStatus = "pending") => + api.get( + `/companies/${companyId}/tools/action-requests?status=${encodeURIComponent(status)}`, + ), + approveActionRequest: (companyId: string, actionRequestId: string) => + api.post(`/tool-gateway/action-requests/${actionRequestId}/approve`, { companyId }), + declineActionRequest: (companyId: string, actionRequestId: string) => + api.post(`/tool-gateway/action-requests/${actionRequestId}/decline`, { companyId }), + createTrustRuleFromActionRequest: ( + companyId: string, + actionRequestId: string, + input: CreateToolTrustRuleFromActionRequest = {}, + ) => + api.post( + `/companies/${companyId}/tools/action-requests/${actionRequestId}/trust-rule`, + input, + ), + + // --- Named MCP gateways --- + listGateways: (companyId: string) => + api.get(`/companies/${companyId}/tools/gateways`), + createGateway: (companyId: string, input: CreateToolMcpGateway) => + api.post(`/companies/${companyId}/tools/gateways`, input), + updateGateway: (companyId: string, gatewayId: string, input: UpdateGatewayInput) => + api.patch(`/tool-gateway/gateways/${gatewayId}`, { ...input, companyId }), + createGatewayToken: (companyId: string, gatewayId: string, input: CreateGatewayTokenInput) => + api.post(`/tool-gateway/gateways/${gatewayId}/tokens`, { ...input, companyId }), + revokeGatewayToken: (companyId: string, tokenId: string) => + api.post(`/tool-gateway/gateway-tokens/${tokenId}/revoke`, { companyId }), + + // --- Audit / Activity --- + /** + * Humanized Activity feed with server-side filters and cursor pagination. + * Returns `{ events, nextCursor }`. + */ + listActivity: (companyId: string, params: ListActivityParams = {}) => { + const search = new URLSearchParams({ companyId }); + 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); + if (params.search) search.set("search", params.search); + if (params.cursor) search.set("cursor", params.cursor); + search.set("limit", String(params.limit ?? 50)); + return api.get(`/tool-gateway/audit?${search.toString()}`); + }, + /** Flat audit sample (no pagination) — used by derived counters/banners. */ + listAudit: (companyId: string, limit = 100) => + api + .get( + `/tool-gateway/audit?companyId=${encodeURIComponent(companyId)}&window=30d&limit=${Math.min(limit, 100)}`, + ) + .then((res) => res.events), +}; diff --git a/ui/src/components/AgentMultiSelect.test.tsx b/ui/src/components/AgentMultiSelect.test.tsx new file mode 100644 index 0000000000..26004b70af --- /dev/null +++ b/ui/src/components/AgentMultiSelect.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentMultiSelect } from "./AgentMultiSelect"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +function setInputValue(input: HTMLInputElement, value: string) { + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" })); + }); +} + +describe("AgentMultiSelect", () => { + let container: HTMLDivElement; + let root: Root | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container.remove(); + document.body.innerHTML = ""; + }); + + it("keeps agent lists compact and searchable", async () => { + const onChange = vi.fn(); + const agents = Array.from({ length: 20 }, (_, index) => ({ + id: `agent-${index}`, + name: index === 17 ? "Search Target" : `Agent ${index}`, + title: `Role ${index}`, + })); + + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toBe("Select agents"); + expect(document.body.textContent).not.toContain("Agent 0"); + + act(() => { + container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const filter = document.body.querySelector('input[placeholder="Filter agents"]'); + expect(filter).not.toBeNull(); + setInputValue(filter!, "search target"); + await flush(); + + expect(document.body.textContent).toContain("Search Target"); + expect(document.body.textContent).not.toContain("Agent 0"); + + act(() => { + document.body + .querySelector('[aria-label="Allow Search Target"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0]?.[0]).toEqual(new Set(["agent-17"])); + }); + + it("previews selected agents and stages changes until save", async () => { + const onSave = vi.fn(); + const agents = Array.from({ length: 6 }, (_, index) => ({ + id: `agent-${index}`, + name: `Agent ${index}`, + })); + + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("Agent 0"); + expect(container.textContent).toContain("Agent 2"); + expect(container.textContent).toContain("and 2 more"); + expect(container.textContent).not.toContain("Agent 4"); + + act(() => { + container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + act(() => { + document.body + .querySelector('[aria-label="Allow Agent 5"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(onSave).not.toHaveBeenCalled(); + const save = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent === "Save"); + act(() => { + save?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(onSave).toHaveBeenCalledWith(new Set(agents.map((agent) => agent.id))); + }); +}); diff --git a/ui/src/components/AgentMultiSelect.tsx b/ui/src/components/AgentMultiSelect.tsx new file mode 100644 index 0000000000..b1a693f900 --- /dev/null +++ b/ui/src/components/AgentMultiSelect.tsx @@ -0,0 +1,224 @@ +import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react"; +import { ChevronRight } from "lucide-react"; +import { AgentIcon } from "@/components/AgentIconPicker"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +export interface AgentMultiSelectOption { + id: string; + name: string; + title?: string | null; + icon?: string | null; +} + +export function AgentMultiSelect({ + agents, + selectedAgentIds, + onChange, + onSave, + loading = false, + disabled = false, + pending = false, + getDescription, + isAgentDisabled, + renderNameSuffix, + triggerLabel, + triggerIcon, + triggerVariant = "outline", + triggerSize = "default", + triggerFullWidth = true, + triggerClassName, + contentAlign = "start", + headerContent, + emptyMessage = "No agents yet.", + showSelectionPreview = true, + onOpenChange, +}: { + agents: AgentMultiSelectOption[]; + selectedAgentIds: Set; + onChange?: (next: Set) => void; + onSave?: (next: Set) => void; + loading?: boolean; + disabled?: boolean; + pending?: boolean; + getDescription?: (agent: AgentMultiSelectOption) => string | null | undefined; + isAgentDisabled?: (agent: AgentMultiSelectOption) => boolean; + renderNameSuffix?: (agent: AgentMultiSelectOption) => ReactNode; + triggerLabel?: string; + triggerIcon?: ReactNode; + triggerVariant?: ComponentProps["variant"]; + triggerSize?: ComponentProps["size"]; + triggerFullWidth?: boolean; + triggerClassName?: string; + contentAlign?: ComponentProps["align"]; + headerContent?: ReactNode; + emptyMessage?: string; + showSelectionPreview?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const [draftAgentIds, setDraftAgentIds] = useState>(new Set(selectedAgentIds)); + const staged = Boolean(onSave); + const workingAgentIds = staged ? draftAgentIds : selectedAgentIds; + + useEffect(() => { + if (open && staged) setDraftAgentIds(new Set(selectedAgentIds)); + }, [open, selectedAgentIds, staged]); + + const normalizedFilter = filter.trim().toLowerCase(); + const filteredAgents = useMemo( + () => + agents + .filter((agent) => { + const description = getDescription?.(agent) ?? agent.title ?? ""; + return `${agent.name} ${description}`.toLowerCase().includes(normalizedFilter); + }) + .sort((a, b) => { + const aSelected = workingAgentIds.has(a.id); + const bSelected = workingAgentIds.has(b.id); + if (aSelected !== bSelected) return aSelected ? -1 : 1; + return a.name.localeCompare(b.name); + }), + [agents, getDescription, normalizedFilter, workingAgentIds], + ); + const selectedCount = selectedAgentIds.size; + const selectedAgents = agents.filter((agent) => selectedAgentIds.has(agent.id)); + + function setSelection(next: Set) { + if (staged) setDraftAgentIds(next); + else onChange?.(next); + } + + return ( +
+ { + setOpen(nextOpen); + onOpenChange?.(nextOpen); + if (!nextOpen) setFilter(""); + }} + > + + + + +
+ setFilter(event.target.value)} + placeholder="Filter agents" + className="h-8" + autoFocus + /> + {headerContent} +
+ {loading ? ( +
+ + +
+ ) : agents.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+ {filteredAgents.map((agent) => { + const description = getDescription?.(agent) ?? agent.title; + const optionDisabled = isAgentDisabled?.(agent) ?? false; + return ( + + ); + })} + {filteredAgents.length === 0 ? ( +
No matches.
+ ) : null} +
+ )} +
+ + {workingAgentIds.size === 0 ? "No agents selected" : `${workingAgentIds.size} selected`} + +
+ {staged ? ( + + ) : null} + +
+
+
+
+ {showSelectionPreview && selectedAgents.length > 0 ? ( +
+ {selectedAgents.slice(0, 3).map((agent) => ( +
+ + {agent.name} +
+ ))} + {selectedAgents.length > 3 ? ( +

and {selectedAgents.length - 3} more

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index ea05266794..bd1156bbeb 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -160,6 +160,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); + expect(container.textContent).not.toContain("Tools & Access"); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ to: "/company/settings", @@ -222,6 +223,11 @@ describe("CompanySettingsSidebar", () => { label: "Adapters", }), ); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/tools", + }), + ); await act(async () => { root.unmount(); diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 9fcfc35d90..972b8dbce6 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -7,6 +7,8 @@ interface EmptyStateProps { /** Optional bold heading rendered above the message. */ title?: string; message: string; + /** Optional secondary line rendered under the primary message. */ + description?: string; action?: string; onAction?: () => void; /** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */ @@ -17,6 +19,7 @@ export function EmptyState({ icon: Icon, title, message, + description, action, onAction, hideActionIcon = false, @@ -26,8 +29,17 @@ export function EmptyState({
- {title &&

{title}

} -

{message}

+ {title ? ( + <> +

{title}

+

{message}

+ + ) : ( + <> +

{message}

+ {description &&

{description}

} + + )} {action && onAction && ( + ) : null} + + ); + } + + if (state === "failed") { + const errorText = result?.errorMessage?.trim(); + const errorCode = result?.errorCode?.trim(); + return ( +
+
+ +
+
Failed · approved by {who}{whenSuffix}
+

+ You approved it and it ran, but the connector returned an error.{" "} + {requestedByLabel} was resumed with this error. +

+
+
+ {errorText || errorCode ? ( +
+ {errorCode ? ( +
+ {errorCode} +
+ ) : null} + {errorText ? ( +

{errorText}

+ ) : null} +
+ ) : null} +
+ ); + } + + if (state === "declined") { + const reason = interaction.result?.reason?.trim(); + return ( +
+
+ +
+
Declined by {who}{whenSuffix}
+

+ The action did not run. {requestedByLabel} was resumed with + your reason and told not to retry the same call. +

+
+
+ {reason ? ( +
+ {reason} +
+ ) : null} +
+ ); + } + + // expired + return ( +
+
+ +
+
+ Expired{when ? ` at ${when}` : ""} — no one responded within 60 minutes +
+

+ The action did not run. If it's still needed, the agent can + request approval again — a fresh card will appear. +

+
+
+
+ ); +} + +function RequestToolActionCard({ + interaction, + state, + resolvedByLabel, + requestedByLabel, + onAcceptInteraction, + onRejectInteraction, + externalReferences, +}: { + interaction: RequestConfirmationInteraction; + state: ToolActionCardState; + resolvedByLabel: string | null; + requestedByLabel: string; + onAcceptInteraction?: ( + interaction: RequestConfirmationInteraction, + ) => Promise | void; + onRejectInteraction?: ( + interaction: RequestConfirmationInteraction, + reason?: string, + ) => Promise | void; + externalReferences?: MarkdownExternalReferenceMap; +}) { + const payload = interaction.payload.toolAction!; + const [rejecting, setRejecting] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [working, setWorking] = useState<"accept" | "reject" | null>(null); + const [actionError, setActionError] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + const isPending = state === "pending"; + const isDestructive = payload.risk === "destructive"; + + useEffect(() => { + if (!isPending) return; + const timer = setInterval(() => setNowMs(Date.now()), 30000); + return () => clearInterval(timer); + }, [isPending]); + + useEffect(() => { + if (state !== "pending") { + setRejecting(false); + setWorking(null); + } + }, [interaction.id, state]); + + async function handleAccept() { + if (!onAcceptInteraction) return; + setWorking("accept"); + setActionError(null); + try { + await onAcceptInteraction(interaction); + } catch { + setActionError("Couldn't submit. Try again."); + } finally { + setWorking(null); + } + } + + async function handleReject() { + if (!onRejectInteraction) return; + setWorking("reject"); + setActionError(null); + try { + await onRejectInteraction(interaction, rejectReason.trim() || undefined); + setRejecting(false); + } catch { + setActionError("Couldn't submit. Try again."); + } finally { + setWorking(null); + } + } + + const countdown = isPending ? formatToolActionCountdown(payload.expiresAt, nowMs) : null; + + return ( +
+ + +
+ + {payload.previewMarkdown} + +
+ + + + {isPending ? ( + <> + {countdown ? ( +
+ + {countdown.text} +
+ ) : null} + +
+
+ + + + Approving runs this action now. + +
+ + {rejecting ? ( +
+